Repository navigation
feat(rules): every engine’s fixtures are case directories under tests/, judged through its own check - #585
Conversation
26111c4 to
de9ef89
Compare
|
@claude /review |
|
Claude finished @theCodeDrift's task in 5m 48s —— View job Review completeReview mode: incremental — read 0 prior review thread(s) before reviewing. Todo list
Overall assessment This is a well-executed, large-but-cohesive rename. [New] One finding, posted inline on
No other correctness issues found in the runner, migration, delivery, reconcile, schema, or test changes. |
de9ef89 to
6a89cc0
Compare
|
@claude /review This is a follow-up review after the first review-fix round (
Lower priority: the |
|
Claude finished @theCodeDrift's task in 8m 48s —— View job Review completeReview mode: incremental — read 1 prior review thread(s) before reviewing. The only prior thread (on Todo list
Per the trigger comment, The four confidence areas — all four reproduce from reading the code1. 2. 3. 4. Migration 12 after an interrupted run — confirmed, a new gap distinct from the fixed one. Lower-priority items — checked, nothing found
|
| fixture: FixtureCase, | ||
| configPath: string | ||
| ): Promise<FixtureFinding[]> { | ||
| const { results } = await runAstGrepScan(fixture.root, [], { |
There was a problem hiding this comment.
[New] Fixture scans inherit the real project .gitignore, so a scope targeting build/ignored output silently never fires
scanCase runs runAstGrepScan(fixture.root, [], ...) — cwd is the case directory, paths is empty. sgWalkArgv([]) treats that as a whole-project walk (isWholeProjectWalk([]) === true in walk-scope.ts), which adds the .taskless//.git/ --globs exclusions, but never passes --no-ignore vcs (by design, per the long comment in scan.ts's sgWalkArgv). That means ast-grep's own ignore-crate walker keeps honoring .gitignore even when rooted at tests/fail/<case>/: the crate climbs to ancestor directories looking for .gitignore files (this is the crate's default parents behavior), so a project-root .gitignore entry like dist/ still excludes tests/fail/<case>/dist/x.js from the scan.
Confirmed repro (matches the PR author's own repro): a rule with files: ['**/dist/**'] and a fail/ case whose only file sits under a case-relative dist/ that the project's .gitignore also excludes. The scoped scan finds nothing (expected, scope matched). The unscoped re-run at line ~178 (scanCase(cwd, ruleId, fixture, unscoped)) is run the exact same way — same cwd, same empty paths — so it is also gitignore-filtered and also finds nothing. outcome.quiet is therefore computed as "no-match" (unscopedFindings.length > 0 ? "scope-excluded" : "no-match"), telling the author "the rule did not match" when the true cause is that the case's own files are invisible to the walker. A pass/ case under the same path passes vacuously for the identical reason.
Worth noting this is not ast-grep-specific: vale/verify.ts's runCase has the same shape (runVale({ cwd: fixture.root, ... }), no paths), and Vale's runVale computes listGitIgnoredEntries(options.cwd) and builds --glob exclusions from it whenever isWholeProjectWalk is true — which it is here too, for the identical reason. So both engines' fixture verification are blind to files that live under a gitignored case-relative path, and both engines' "quiet fail case" re-run inherits the same blindness, misreporting a gitignore exclusion as "the rule did not match."
Compounding factor: deriveFixturePath (fixture-convert.ts) derives case paths straight from the rule's own files:/ignores: globs, so migration 12 and delivery's convertLegacyFileSet can mechanically produce exactly this shape for any rule scoped at build/**, dist/**, coverage/**, etc. — i.e. the rules most likely to hit this are the ones whose whole purpose is to say something about build output.
This looks like a real scoping gap in fixture verification, not a hypothetical: it would benefit from either excluding .gitignore effects for fixture scans (mirroring what an explicit user-named path already gets — isWholeProjectWalk already carves out that case for a real whole-project check, but [] as "no explicit path" collapses the "run this one case" intent into "whole project" here), or at minimum a third quiet value distinguishing "gitignored" from "no-match" so the error message points at the right thing.
| const testsDirectory = ruleTestsDirectory(cwd, engine, ruleId); | ||
|
|
||
| const entries = await bucketEntries(testsDirectory); | ||
| const stray = entries.find( |
There was a problem hiding this comment.
[New] readRuleFixtures/bucketCases have no dotfile exemption, and no empty-case guard
Two related strictness gaps in this newly-unified reader:
-
Dotfiles are treated as strays/loose files, not ignored.
bucketCases(line 128) flags any non-directory entry inpass//fail/as an error (A fixture case is a directory: …), andreadRuleFixtures's stray check here (line 171) flags anytests/entry that isn'tpass/fail. Neither skips.DS_Store,.gitkeep, or any other dotfile. A macOS-authoredtests/.DS_Store, or atests/.gitkeepsomeone adds to keep an otherwise-emptytests/tracked by git, breaksverifyfor the entire rule with "is not a fixture bucket" / "is not a directory" — not a warning, a hardthrowthatrunSgFixtures/verifyValeRule/the runtime verifier all catch and turn intocoverage: "none"with an error, discarding every real case the rule actually has. Given this reader is reached from ordinary developer machines (not just CI),.DS_Storeshowing up here is a realistic, not hypothetical, occurrence. -
A zero-file case directory passes vacuously and still counts toward
coverage: "both".bucketCasesonly requires eachpass//fail/entry to be a directory — nothing checks it contains at least one file. Git does not track empty directories, so a case accidentally left with no files (e.g. a bad migration, a.gitignoreexcluding everything inside it — see the sibling finding onsg-fixtures.ts) silently clones as an absent directory for one contributor and as an empty, vacuously-passing one for another, andclassifyCoveragecannot tell "this case exercises nothing" from "this case exercises something and found nothing", because apass/case passing on zero files and apass/case passing on real files that stayed clean produce the identical outcome.
Neither is the collision/data-loss bug already fixed in 6a89cc0e; both are new to this unification (the old three per-engine readers had their own, different rejection rules, per this file's own doc comment above).
| ); | ||
| return; | ||
| } | ||
| const claimed: ClaimedCases = new Set(); |
There was a problem hiding this comment.
[New] A rule interrupted mid-migration, with more than one legacy test file, can get permanently stuck
claimed is a fresh, in-memory Set per call to migrateSg (and likewise migrateVale) — it is never seeded from case directories that already exist on disk under ruleDirectory/tests/, only built up across the legacy files processed within this one run (the fix from 6a89cc0e addresses exactly that: collisions within a single run).
That leaves a gap one level up. Suppose a rule has two legacy files, no-eval-20260101-test.yml (invalid: [eval(a)]) and no-eval-20260215-test.yml (invalid: [eval(b)]), and the process is killed after the first file is fully converted and its source removed, but before the second file is even opened — e.g. a crash or OOM between iterations of the for (const entry of await entriesOf(legacy)) loop, not a partial write within one iteration (that case is the one 6a89cc0e's tests cover: "finishes a conversion whose files were written but whose source stayed").
On the next run: entriesOf(legacy) now returns only the second file (the first's source is already gone). claimed starts empty again, so the second file's invalid: snippet claims fail-01 (the first available ordinal) instead of fail-02. writeNew finds tests/fail/fail-01/fail-01.ts already holds eval(a)\n from the first file, which is not byte-identical to eval(b)\n, so it returns false; apply records "no-eval: tests/fail/fail-01/fail-01.ts already exists with other content" and leaves the second source in place. Every subsequent run repeats exactly this — the in-memory claim never advances past fail-01 because nothing seeds claimed from what migration already wrote to tests/ in a prior, interrupted run. The rule is left permanently straddling .tests/ and tests/, reporting the same note on every init/upgrade, with no path to resolution short of a human manually renumbering or deleting the stuck file.
This isn't data loss (the source file survives, per the 6a89cc0e fix), but it is a correctness gap in the "a rerun after a partial failure finishes the job" claim in this function's own doc comment (lines 58-61) — it only holds for a crash within processing one legacy file, not a crash between files. Worth seeding claimed by scanning the existing tests/pass/+tests/fail/ case names already under ruleDirectory before the loop starts, the same way the loop already knows to skip a destination that holds the right bytes.
| | { findings: FixtureFinding[]; notices: string[] } | ||
| | { outcome: ValeRunFailure } | ||
| > { | ||
| const outcome = await runVale({ cwd: fixture.root, configPath, timeoutMs }); |
There was a problem hiding this comment.
[New] Same .gitignore-hides-fixture gap as ast-grep's, through Vale's own gitignore handling
runCase calls runVale({ cwd: fixture.root, configPath, timeoutMs }) with no paths. In vale/run.ts, options.paths ?? [] then makes isWholeProjectWalk([]) true, which triggers wholeProjectTargets(options.cwd) and listGitIgnoredEntries(options.cwd) → gitIgnoredExclusionGlobs(...) fed into Vale's --glob. That machinery exists specifically to keep a real whole-project check out of dist/, vendored trees, etc. (per the comment at run.ts:618-626), but here options.cwd is the fixture case directory, not the project root — so a project .gitignore entry like dist/ excludes tests/fail/<case>/dist/x.js from the Vale run exactly the way it excludes it from the ast-grep run in sg-fixtures.ts (see that inline comment for the full repro). The same applies to the unscoped re-run a few lines below (built from buildIsolatingConfig): it goes through runCase the same way, so it inherits the same blind spot and would also misreport a gitignore exclusion as "the rule did not match" rather than naming the real cause.
Flagging this mainly so it's considered together with the ast-grep instance rather than fixed once and assumed solved for both engines — the mechanism differs (native ignore-crate behavior vs. this file's explicit git-status-derived glob) but the user-visible effect (a fail/ case under a case-relative gitignored path goes quiet, a pass/ case passes vacuously) is identical.
…/, judged through its own check
…tten Migration 12 swallowed EEXIST and still removed the source, so a destination holding other bytes silently cost a fixture. A destination now counts as written only when it holds the same bytes, which keeps a rerun after an interrupted run a no-op; otherwise the source stays and is named. Case names are claimed across all of a rule's legacy files, read in name order, so a rule's second dated test file continues at fail-02.
…bucket From the follow-up review round. A case the engine never read was judged as if it had been: a pass case passed on no evidence, and a fail case blamed the pattern. Reproduced with a fixture under a gitignored path, an empty file, and a path ast-grep's glob dialect excludes but node:path's does not. - ast-grep: each case scan passes --inspect entity, which names every file a rule was applied to. No such file means never read; an unscoped run says whether the scope or the walk was the cause. - Vale: a case with no finding is linted with a probe rule under the same matchers, and once more without them. - A run killed by a signal is a failure, not a scan that found nothing. - The reader skips .DS_Store, Thumbs.db, desktop.ini and .gitkeep, refuses a case with no file, and refuses a leftover .tests/ beside the rule. - Migration 12 numbers past cases already in tests/ (a file fixed after an earlier run), still recognises a rerun after an interrupted one, and removes sg test's __snapshots__. Delivery's warning says what it did.
6a89cc0 to
63e61c9
Compare
Stack (root → tip):
The rename and everything that only works with it, in one layer so no state in between ships.
Runner
RULE_TESTS_DIRECTORYistests;FixtureLayoutiscase-directoriesfor every engine. Migrations 5, 9 and 10 readLEGACY_RULE_TESTS_DIRECTORY, so their behavior is unchanged.rules/fixtures.ts): loose bucket files and straytests/entries are refused by path.rules/sg-fixtures.tsscans each case withrunAstGrepScanunder a one-rule config. Replacessg testand the deleted stdin re-scan. A quietfail/case is re-run without scope, to say "scope excluded" or "did not match"..vale.ini, not[*].fixturesandfindings.sg-fixture-id-matches-ruleis removed.Migration and delivery
.tests/for every engine, at paths each rule's scope admits, and names anything it leaves behind.tests/and a leftover.tests/as unsigned fixtures.tests:rule migration writes the new layout. Corpusreference.jsonmoves to version 4..taskless/rules/andexample/are migrated, with every case named for what it shows.Bugs found and fixed:
"note": nullmadeverify --jsonfail its own schema. A schema test now runs realverifyoutput, and I confirmed it fails without the fix.EEXISTand removed the source anyway. A destination now counts only if it already holds the same bytes; otherwise the source stays and is named. Case names continue across a rule's test files.Second review round: a case the engine never read fails, whatever its bucket. ast-grep's own
--inspect entitynames every file a rule was applied to, and Vale gets a probe rule under the same matchers. Reproduced before the fix: a pass case under a gitignored path, holding an empty file, outside the scope, or behind ast-grep's*-crosses-/glob dialect all passed without being read. Also: a signal-killed scan is a failure; the reader skips.DS_Store/.gitkeepand refuses an empty case and a leftover.tests/; migration 12 numbers past existing cases and finishes an interrupted run.Typecheck, lint and the full suite pass on this layer alone (2,826 tests). 14/14 of this repo's rules pass
pnpm cli verify, about 100 cases in 2.2 s.Size: large, deliberately. The directory name is read by the runners, migration, delivery and reconcile, and splitting them would ship a layer where
verifyand delivery disagree. Everything separable is in #584.Stack: #583 spec → #584 groundwork → #585 the
tests/rename → #586 recipes & archive. Every layer is green on its own; land it withgh stack merge. Design:openspec/changes/unified-rule-fixtures/design.md(archived on #586).