feat(test): report ast-grep fixture findings - #401
Conversation
`test --json` carried real `findings` for Vale and runtime rules and an empty array for ast-grep. That was truthful and it left the defect #386 exists for unfixed: a rule whose `message` interpolates its metavariables can name them in the wrong order, fire on every `invalid:` snippet, stay quiet on every `valid:` one, and be reported green. `ast-grep test` never renders the message, so nothing could see it. Each fixture snippet is replayed through `ast-grep scan -r <rule> --stdin --json=stream`. Three alternatives were measured and rejected: - `sg test` cannot carry findings at all. On the vendored 0.45.3 binary `test --help` offers no `--json` and no output-format flag. - `check` over the rule's `.tests` directory returns no results, because the fixtures are inline YAML scalars rather than documents, so the workaround that exists for Vale does not transfer. - Materialising each snippet as a file needs a `language:` to extension mapping the CLI does not own. `language:` takes ast-grep's own spelling and the set belongs to the binary, so a drifting map would silently scan a snippet as the wrong language. `--stdin` takes the language from the rule's own `language:` key, so there is no mapping to keep in step and no temp file to clean up. Two properties were measured rather than assumed: `files:` globs do not suppress a stdin scan, which a path-scoped rule would otherwise have been silently invisible to; and `-r` isolates a rule ast-grep cannot parse to that rule alone, rather than aborting a whole config. A finding names the test YAML that declares the snippet, at the snippet's real line and column there, so the author can open it. Findings are gathered after `sg test` has decided the verdict and cannot change it.
Adds "Test reports the findings an ast-grep rule's fixtures produced" to `cli-rule-validation` as an ADDED requirement, so nothing standing is restated and nothing can be dropped on archive. ADDED rather than MODIFIED deliberately. The standing scenario "The findings array is present and empty rather than absent" lists four WHENs, one of them "an engine that does not surface fixture findings". After this change no engine is in that state, so the disjunct is inert — but it is not false, and the other three are still live and still tested. Restating a ten-scenario requirement to delete one clause is the operation that silently drops scenarios, and the clause costs nothing standing. Archive dry-run compared requirement and scenario title sets before and after: 9 requirements and 48 scenarios before, 10 and 54 after, with nothing in the before-only set either time.
|
@claude /review |
|
Claude finished @thecodedrift's task in 6m 49s —— View job Claude finished reviewing this PRReview mode: incremental — read 0 prior review thread(s) before reviewing.
FindingsI posted 3 inline comments, ranked by severity:
Everything else held up under an 8-angle pass (line-by-line diff scan, removed-behavior audit, cross-file call-site tracing, reuse/simplification/efficiency, altitude, and a CLAUDE.md conventions check):
|
…ndent from the first non-empty line A test file's unquoted `id: 123` resolves to the JS number 123, which never equals the string rule id, so the file was excluded from its own rule's fixtures: findings read as empty while the rule still passed. Reachable when the rule file quotes its id and the test file does not; an unquoted numeric id in the rule file is rejected by the schema before fixtures run. The same comparison in `fixtureCoverage` carried the same gap and is fixed with it. YAML detects a block scalar's indentation from its first non-empty line. The anchor read the literal first line instead, so a snippet written with a leading blank reported indent 0 and shifted every column left by the real indent — wrong rather than imprecise, since that path still maps per line.
Both inline findings were real and are fixed in — AI Coding Agent |
testreported the findings a rule's fixtures produced for Vale and runtime, and an empty array for ast-grep. That was truthful, and it left the half of #386 that matters most for ast-grep unfixed: a rule whosemessageinterpolates its metavariables can name them in the wrong order, fire on everyinvalid:snippet, stay quiet on everyvalid:one, and be reported green.ast-grep testnever renders the message, so nothing could see it.The route, and what ruled out the alternatives
Each fixture snippet is replayed through
ast-grep scan -r <rule> --stdin --json=stream. Three alternatives were measured against the vendored 0.45.3 binary first:sg testast-grep test --helpoffers--filter,--skip-snapshot-tests,--update-all,--interactive,--include-off,--colorand nothing else. No--json, no output-format flag. Its only machine-readable output is the summary lineparseTestSummaryalready reads.checkover the rule's.testsdirectory, as Vale doespnpm cli check .taskless/rules/sg/ci-uses-workspace-cli/.tests --jsonreturns{"success":true,"results":[]}. The fixtures are inline YAML scalars undervalid:/invalid:, not documents, so there is nothing for a walk to find.language:→ extension mapping the CLI does not own. See below.--stdinwas the measurement that changed the design:The message comes back rendered, with metavariables interpolated — which is the entire point of the feature.
The language mapping: there isn't one, and that is the result
No
language:→ extension mapping was added, because--stdindoes not need one. ast-grep parses the rule's ownlanguage:key and picks the grammar itself.This was the main risk flagged going in, and it was a real one. There is no such list anywhere in this repo today:
packages/cli/src/schemas/ast-grep-rule.tsrequireslanguageas a key and never validates its value. The set belongs to the binary, andlanguage:takes ast-grep's own spelling — this repo's ownci-uses-workspace-cliusesYaml, notyaml. A mapping we maintained would drift and silently scan a snippet as the wrong language. Not writing one is strictly better than writing one, so the module documents why it does not exist rather than leaving the next person to re-derive it.Two further properties were measured rather than assumed:
files:globs do not suppress a stdin scan.ci-uses-workspace-clirestricts itself to.github/workflows/*.yml, and the stdin document is namedSTDIN. It fires on the same snippet identically with and without thefiles:key — the glob filters the file walk, which stdin bypasses. Had this gone the other way, every path-scoped rule would have silently reported nothing.-risolates a rule ast-grep cannot parse. An unrecognisedlanguage:exits 8 withFail to parse yaml as RuleConfigand emits no JSON. Because-rloads exactly one rule file rather than the assembled config, that cannot take any other rule's report down with it — which is precisely the silent config-wide abort CLAUDE.md warns about.What
filereportsThe test YAML that declares the snippet, cwd-relative and POSIX, matching what Vale reports. Not a temp path and not ast-grep's
STDIN: both are unopenable, and the author's next move after reading a wrong message is to go and edit that snippet.The position maps back to the snippet's real line and column in that file, better than the
<test-file>.yml:invalid[0]the earlier exploration proposed.yaml'sparseDocumentgives each scalar's byte range; the block scalar's content starts on the following line and every content line carries the same stripped indentation, so file line iscontentStart + snippetLineand file column issnippetColumn + indent. Verified against a real fixture: the reported…-test.yml:33:9lands exactly onrun: npx @taskless/cli check.This is done only for literal block scalars (
|), the spelling ast-grep's own test files use. A folded scalar (>) joins lines and a plain or quoted scalar can carry escapes, so in neither is snippet line N file line start+N. Those anchor to the snippet's first line instead — still openable, and honest about the precision available, rather than reporting a confidently wrong line.Mutation-check results
Both are reported, because a test that passes either way is worse than none.
replace $SECOND with $FIRSTtoreplace $FIRST with $SECOND— which fires in exactly the same places and whichsg testreports green either way — turned 4 ast-grep tests red, including the headlinereports the rendered message, so swapped metavariables cannot pass. Vale's and runtime's tests stayed green, confirming the mutation was scoped to the rule under test.expected [ 'mutant-1790199804073.ts', …(1) ] to deeply equal [].That second test redirects the CLI's whole
TMPDIRto a private directory and asserts it is empty, rather than diffing the sharedtmpdir()— which other suites write to concurrently, so a diff there would have been measuring the rest of the run.Residual limitations
ast-grepprocess per snippet. Measured at ~7ms each (15 snippets in 0.112s), so it is not worth batching, but it is linear in fixture count.language:ast-grep rejects reports no findings.verifyis what reports the malformed rule; this path stays silent by design rather than reporting the same defect twice."note": null, which thetestpayload schema rejects (noteis an optional string there). Normalised to absent in the collector rather than intoCheckResult, which would have changed the shapecheck --jsonhas been emitting.OpenSpec
ADDED, not MODIFIED. The standing scenario "The findings array is present and empty rather than absent" lists four WHENs, one being "an engine that does not surface fixture findings". After this change no engine is in that state, so the disjunct is inert — but not false, and the other three are still live and still tested. Restating a ten-scenario requirement to delete one clause of one WHEN is the operation that silently drops scenarios, so the clause is left for a change with its own reason to touch that requirement.
Archive dry-run compared requirement and scenario title sets before and after: 9 requirements / 48 scenarios before, 10 / 54 after, with an empty before-only set both times. Archived for real on this branch — it is the tip, and there is no follow-up.
Gates
pnpm build,pnpm typecheck,pnpm lintall pass.pnpm test: 106 files / 1786 tests, up frommain's 1778, with none broken and no existing assertion weakened. The one standing test that asserted ast-grep reports[]was replaced with a case that is still true — a rule whose fixtures matched nothing — rather than deleted.Vale and runtime behaviour is untouched; the shared human renderer needed no change, since it was already engine-agnostic. No
--include-fixturesoncheckand no--verbose, both explicitly out of scope.Fixes #386