* fix(cli): make GitHub Actions setup flow idempotent and stop bundling local changes (#904)
The setup flow opened duplicate PRs and swept unrelated local changes into
the generated branch. Two root causes in `openWorkflowPullRequest`:
1. Non-idempotent: `findUniqueBranchName` only avoided a *local* branch
collision by appending a timestamp, and nothing checked for an
already-open setup PR. A re-run (the workflow file lives only on the prior
PR's branch, so it's re-written each run) minted `…-<timestamp>` branches
and opened a second PR. Now we `gh pr list` for an open PR whose head is
under the setup prefix and surface it (`pr-exists`) instead of opening a
duplicate.
2. Dirty-tree bundling: `git checkout -b <new> origin/<default>` carries the
index across and the whole-index `git commit` then committed whatever the
user had staged (modified app files, a deleted/rewritten lockfile). Now we
bail (`working-tree-dirty`) when the tree has tracked changes other than
the workflow file itself, falling back to staging the workflow file. The
workflow path is excluded from the check so the v1→v2 upgrade flow (which
modifies the tracked workflow in place) still works.
Both callers handle the new `pr-exists` status; the runner / gh-availability
probe are now injectable for hermetic tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cli): harden setup-flow fix from adversarial review
Findings from an adversarial review+deslop pass over the #904 fix:
- fix(correctness): normalize `workflowRelative` with `toForwardSlashes` so the
`:!` exclude pathspec matches git's forward-slash repo paths on Windows.
Without it, `path.relative` yields backslashes there, git's magic pathspec
doesn't treat them as separators, and the upgrade flow (tracked workflow
modified in place) would always report `working-tree-dirty` and never open
the PR. Applied in `openWorkflowPullRequest` and `stageWorkflowFile`.
- fix(correctness): on the upgrade flow's `pr-exists` path, restore the
original @v1 workflow content. `pr-exists` returns without touching git
state, so the @v2 edit written before the call would otherwise linger
uncommitted — matching the pr-opened path's "bump lives only on the PR
branch" contract.
- chore: extract the `gh pr list` cap to `GH_PR_LIST_MAX` in constants.ts
(AGENTS.md: no inline magic numbers).
- test: assert the dirty-tree guard skips add/commit/push (not just checkout),
the happy path stages ONLY the workflow file (no `git add -A`/`.`), and
branch-pushed keeps the branch (no `git branch -D`) and restores HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): trigger duplicate-PR guard on match presence, not URL truthiness
Bot review (Cursor) flagged that the idempotency short-circuit keyed on a
truthy URL: if `gh pr list` returned a matching setup head branch with a
missing/empty `url`, `findExistingSetupPullRequest` returned null and the
flow could still mint a branch and open a duplicate PR — the exact failure
the guard exists to prevent. `gh` always populates `url` for `--json url`, so
this is defensive, but the headline #904 guarantee shouldn't hinge on it.
Return the matched PR object and short-circuit on its existence; use its url
only for the `pr-exists` message (empty when absent). Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: skip pnpm hardening check for monorepo sub-packages
Only check pnpm hardening settings at the workspace root or standalone
pnpm projects. Sub-packages of a pnpm workspace inherit hardening from
the workspace root's pnpm-workspace.yaml, so emitting diagnostics with
synthesized file paths for each sub-package is incorrect.
Fixes#878
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
* chore: add changeset for pnpm hardening fix
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
* chore: format test file
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
* refactor: rename rootDirectory to scanDirectory in checkPnpmHardening
The parameter name was misleading - call sites pass scanDirectory,
not a root directory.
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
* refactor: deslop pnpm-hardening monorepo-skip change
Defer findMonorepoRoot inside the !hasWorkspaceFile guard so the
ancestor walk only runs when its result is used, and fold the
parent-workspace check into one short-circuit condition (one fewer
nesting level, behavior unchanged). Extract expectMissingHardeningWarnings
to collapse four repeated assertion groups in the monorepo test block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Skosh <skoshx@users.noreply.github.com>
Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rules): add 8 mined correctness, performance, a11y, and security rules
Mined from a 12-cluster pass over the design/engineering brain and
synthesized into docs/rule-candidates-backlog.md. Ships the highest-signal,
lowest-noise batch, each with adversarial + FP-regression tests:
- no-call-component-as-function (Bugs)
- no-create-ref-in-function-component (Bugs)
- no-async-effect-callback (Bugs)
- no-json-parse-stringify-clone (Performance)
- img-missing-dimensions (Performance)
- no-img-lazy-with-high-fetchpriority (Performance)
- dialog-has-accessible-name (Accessibility)
- auth-token-in-web-storage (Security)
Adds the shared has-jsx-spread-attribute util and registers the three
in-house ported-bucket rules in RULES_NOT_PORTED_FROM_EXTERNAL.
* fix(rules): drop img-missing-dimensions; make component rules shadow-safe
- Drop `img-missing-dimensions`: width/height attributes are only one of
several valid ways to reserve space (CSS aspect-ratio, container sizing),
and the authoritative signal lives in stylesheets a static linter can't
see. This is a runtime check (Lighthouse `unsized-images`), so a syntax
rule would false-positive on any CSS-sized image.
- `no-call-component-as-function`: resolve the callee through its scope
symbol instead of by name, so a parameter/local shadowing a component
name is no longer flagged. The component signal is the binding's
declaration (same-file component returning JSX) or an imported binding
also rendered as JSX.
- `no-create-ref-in-function-component`: skip a shadowing local `createRef`
(only flag React's import / `React.createRef`).
- Adds shadow-safety regression tests for both.
* fix(rules): tag no-call-component-as-function test-noise (eval finding)
An OSS eval sweep (react-use, radix primitives, excalidraw, mantine;
~2.8k diagnostics) showed every real-world hit of this rule was a test
render-helper (`render(Wrapper({...}))` in `.test.tsx`/`.story.tsx`) —
harmless for hookless wrappers and the dominant firing surface. Tag it
`test-noise` so it skips test/story/playground files and only nags
shipped code. The sweep also confirmed dialog-has-accessible-name (2 real
a11y bugs in excalidraw) and no-create-ref-in-function-component (1 real
bug in a mantine story) as true positives with zero false positives.