Skip to content

fix(cli): require both fixture buckets for an ast-grep rule - #156

Open
thecodedrift wants to merge 3 commits into
fix/engine-capabilitiesfrom
fix/sg-coverage
Open

fix(cli): require both fixture buckets for an ast-grep rule#156
thecodedrift wants to merge 3 commits into
fix/engine-capabilitiesfrom
fix/sg-coverage

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Aug 24, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Stacked on #155. Review only the last two commits; the base is that PR.

verify Layer 2 checked that a -test.yml exists, never what was in it — and ast-grep test passes an empty invalid: bucket (1 passed; 0 failed, exit 0). So a rule that flags nothing reported success:

$ taskless test .taskless/rules/sg/no-bare-fetch --json
{"ok":true,"rules":[{"engine":"sg","ruleId":"no-bare-fetch","ok":true,"errors":[],"ran":true}]}
$ taskless check                # source contains `fetch(url);`
No issues found.

We already close this on the Vale side. rules/vale/verify.ts has ValeFixtureCoverage and inspect.ts fails any rule whose fixtures !== "both""half a claim". The sg branch of that same function had no equivalent, and cli-rule-validation specified the Vale rule with no sg counterpart, so the spec actively endorsed the gap.

Now symmetric: a rule whose invalid: bucket is empty across all its .tests/*-test.yml files has never been shown to fire and does not pass. It reads the author's own YAML with the parser already imported in verify.ts — derives nothing, guesses at nothing — and generalizes past $$$ to any rule that matches nothing.

⚠️ This rejects rules that passed before. minor is defensible; patch was chosen (pre-1.0) with the warning carried in the changeset body.

Fixes #152

What #152 reported vs. what is true

The report was that a trailing $$$ does not match a zero-argument call. That is false as worded — and a worse variant is real. Measured against the bundled 0.41.0 and now pinned:

pattern matches, given foo(), foo(1), foo(1,2), foo(1,2,3)
foo($$$) all four, including foo()
foo($A, $$$) only foo(1,2), foo(1,2,3)not foo(1)
foo($$$, $A) only foo(1)
foo($A, $$$, $B) only foo(1,2)

The pattern's , is itself an AST node, and under default smart strictness every pattern node must match, so a call with no comma cannot match a pattern that has one. $$$ matching zero nodes is fine — the separator is what fails. $$$ and $$$ARGS behave identically; statement blocks are unaffected because ; lives inside the statement node.

Upstream closed this as working-as-intended (ast-grep/ast-grep#1365) and 0.45.2 behaves identically, so there is no version to wait for.

The remedy is strictness: ast inside a pattern object — and note it moves the boundary from ≥2 to ≥1, not to zero, since $A still has to bind something. strictness at rule level is rejected as an unknown field and fails the whole scan, so placement is load-bearing; a test pins that too. For a leading $$$, strictness: ast does not help and any: with one branch per arity is the fix.

The coverage gate is what surfaces this. It forces an invalid: case, that case fails against the original pattern, and strictness: ast fixes it — the trap becomes visible at authoring time instead of in production.

Zero shipped rules were affected

Every $$$ in this repo is the sole element of its argument list. The exposure was entirely prospective: no recipe mentioned $$$ at all, and create-sg-rule.txt pointed agents at the upstream docs, which say only "zero or more AST nodes" — the omission that makes this a trap.

Recipes

create-sg-rule.txt gains a step for the separator trap with the measured table and both remedies, and the fixture step now states that both buckets must be non-empty and that a rule with an arity boundary needs a case on each side of it. improve-rule.txt gains a note to check for a comma-adjacent $$$ before assuming a pattern is merely too narrow, and to re-confirm both buckets after the service rewrites a rule — narrowing a pattern to kill a false positive can leave invalid: with nothing the rule still matches.

Inserting a step shifted four numbers; three in-file cross-references were corrected, and every recipe was grepped for step [0-9] to catch others. (A previous change here shipped an off-by-one exactly this way.)

verify-examples.ts gains a strictness: ast entry, so the safe form reaches agents through verify --schema — it previously showed only the standalone $$$.

Tests

One case per coverage state in verify.test.ts (nothing covered an empty bucket before), the $$$ behavior pinned in ast-grep-vendor-contract.test.ts including rule-level strictness hard-failing, and two cases pinning the language-field claims added to the recipe: an unrecognized spelling takes the whole scan down, and Tsx/TypeScript are distinct parsers rather than aliases.

OpenSpec delta on cli-rule-validation adding the sg bucket scenario. Not archived; not the tip.

Copilot AI lite review requested due to automatic review settings August 24, 2026 05:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 57s —— View job


Review in progress

Review mode: incremental — read 0 prior review thread(s) before reviewing. (No prior inline review threads exist on this PR; the only prior review activity was Copilot's quota-limit failure, so there is nothing previously-raised to classify as addressed/unchanged. This is effectively the first substantive review.)

  • Read .prior-review.json
  • Gather diff (git diff origin/fix/engine-capabilities...HEAD)
  • Manual line-by-line pass (correctness, cross-file tracing, removed-behavior)
  • Finder agents: reuse/simplification/efficiency/altitude, cross-file tracer, conventions/removed-behavior
  • Verify candidate findings
  • Post inline comments for concrete issues
  • Post final top-level summary

thecodedrift and others added 3 commits August 23, 2026 23:29
`verify` Layer 2 checked that a rule's `-test.yml` existed and never read
what was in it, and `ast-grep test` reports an empty `invalid:` bucket as
`1 passed; 0 failed` and exits zero. A rule whose fixtures were all
`valid:` therefore reported `ok: true, ran: true` while `check` found
nothing anywhere.

`test` now counts the `valid:`/`invalid:` entries across every test file a
rule owns and requires both, mirroring `ValeFixtureCoverage` state for
state with ast-grep's own vocabulary. This rejects sg rules that passed
before; the changeset says so.

Also pins the `$$$` separator behaviour behind #152 as a vendor contract:
a comma-adjacent `$$$` is not "zero or more", because the comma is itself
a node that must match under the default `smart` strictness. Upstream
calls this intended and 0.45.2 is identical, so `verify --schema` gains a
curated `strictness: ast` example rather than a version bump.

Fixes #152

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms
…e is written

`create-sg-rule` sent an author to upstream for the rule format and said
nothing about the two fields that fail without local feedback.

The `$$$` separator gets its own step, with the arity table measured against
the pinned binary: a comma-adjacent `$$$` is not "zero or more", because the
comma is itself a node that must match under the default `smart` strictness.
Both remedies are stated as the different things they are — `strictness: ast`
inside a pattern object for a trailing `$$$` (moving the boundary from two
arguments to one, not to zero), `any` per arity for a leading one — and the
fixture step now asks for a case on each side of an arity boundary, since a
one-sided rule reports `1 passed; 0 failed` and exits zero.

`language:` gets the same treatment in the authoring step, rendered from
`AST_GREP_LANGUAGES` rather than transcribed. Nothing local validates that
field, and it fails two ways: a spelling ast-grep does not recognize takes the
whole scan down, and `TypeScript` over a `.tsx` tree reports nothing and looks
clean. Both are now pinned in the vendor contract, so the prose is quoting the
binary rather than describing it.

`improve-rule` carries the two notes that only matter on a rewrite: read the
pattern for a comma-adjacent `$$$` before reporting a rule as too narrow, and
re-check both fixture buckets after the service returns a narrowed rule.

Refs #152

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms
The suite and its `atLanguage` helper moved down to fix/engine-capabilities,
so that the recipe claim in route.txt and the binary behaviour pinning it
land in the same change. Rebasing brought them here, leaving a duplicate
declaration.

The $$$ arity suite stays — that one pins claims made by this branch's own
recipe prose in create-sg-rule.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

The earlier review on this PR stopped before posting findings (the checklist above is unfinished), and the branch has changed since — it was rebased onto a corrected base and carries new commits. Please review the current head.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 45s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no prior inline threads, reviews, or comments beyond Copilot's quota-limit failure notice, so there is nothing previously-raised to classify as addressed/unchanged. This is effectively the first substantive review of this PR — resuming after an earlier run that stopped mid-checklist, now against the current rebased head (git diff origin/fix/engine-capabilities...HEAD).

  • Read .prior-review.json
  • Gather diff (git diff origin/fix/engine-capabilities...HEAD)
  • Manual line-by-line pass (correctness, cross-file tracing, removed-behavior)
  • Finder agents (8): reuse/duplication, cross-file tracer, removed-behavior auditor, simplification, CLAUDE.md conventions, efficiency, altitude, line-by-line correctness scan
  • Verify candidate findings against the actual code and git history
  • Post inline comments for concrete issues
  • Post final top-level summary

Findings posted inline (all [New])

  1. packages/cli/test/ast-grep-vendor-contract.test.ts:658-697 — a leftover duplicate describe("the language field", ...) block. Confirmed against origin/fix/engine-capabilities: the base already carries this exact 3-test suite (now at line 526 in the current file); this second copy (2 of the 3 tests, verbatim) is a rebase artifact. Commit ee1a034 explicitly says its purpose was removing this duplication but only deleted the duplicate atLanguage helper, missing the describe block itself.
  2. packages/cli/src/rules/verify.ts:227-247 (fixtureCoverage) — counts a test file toward a rule's coverage by filename glob only, never checking the file's own id: against ruleId, while sg test --filter (line 322) resolves against that id:. A misnamed/copy-pasted test file's id: inside the correct rule directory would be counted here but silently skipped by sg test, reopening the exact "never actually shown to fire" gap this PR closes, through a different door. Narrow scenario, but real.
  3. packages/cli/src/rules/verify.ts:217-230fixtureCoverage re-lists the same directory and re-applies the identical filename filter that validateRequirements (lines 172-175) already computed earlier in the same verifyRule() call. Same-file duplication of the "what counts as this rule's test file" predicate, worth extracting into one shared helper.

Noted but not filed as separate blocking comments

  • coverageOf (verify.ts:190-198) duplicates vale/verify.ts's function of the same name/shape, and the inspect.ts message-construction for "none"/"-only" states is duplicated near-verbatim between the sg and Vale branches of testOneRule (lines 218-229 vs 255-262). Multiple finder agents flagged this, but it's a documented, deliberate mirroring (see the doc comments at verify.ts:46-56 and inspect.ts:219) rather than an accidental copy — a reasonable simplification target (a shared classifyCoverage/describeFixtureCoverage helper), not a defect.
  • Swallowed readdir/readFile/parse failures inside fixtureCoverage (lines 219-236) produce no diagnostic — but this is explicitly the documented intent (verify.ts:209-211: a file that can't be read/parsed "contributes nothing," since sg test itself will report the malformation), so treating this as a gap would go against a stated, defensible design choice.
  • The fixture-coverage gate is a non-emptiness check; it can't by itself verify a populated invalid: bucket actually straddles a $$$-adjacent-comma arity boundary (the [sg] Trailing $$$ breaks sg rules #152 mechanism). That's out of scope for what this PR sets out to do (bucket coverage, not pattern-shape linting) and is already covered by recipe prose per the PR description — flagging only as a forward-looking design note, not a finding against this diff.
  • Repeated { valid: false, errors: [...], passed: 0, failed: 0, fixtures: "none" } skip-result literals (5 call sites in verify.ts) and the step-renumbering fragility in create-sg-rule.txt are minor, defensible simplification opportunities, not correctness issues.

No security, correctness-blocking, or test-adequacy issues beyond what's posted inline above. The core design (reading the author's own YAML for bucket counts rather than ast-grep test's unstructured output) is well-justified and consistent with this codebase's documented history with parseTestSummary/#112.

Comment on lines +658 to +697
/**
* How a wrong `language:` fails — the two shapes `create-sg-rule.txt` warns
* about where the field is written.
*
* Nothing of ours catches either one first: the vendored
* `src/generated/ast-grep-rule-schema.json` types `$defs.Language` as a bare
* string with no enum, and `verify` never reads the field. So the binary's
* response IS the contract, and a recipe telling an author what to expect is
* quoting it.
*/
describe("the language field", () => {
it("fails the whole scan on a spelling it does not recognize", () => {
// `C#` is the plausible wrong spelling of `CSharp`, and getting it wrong
// is not a rule that quietly matches nothing: ast-grep cannot parse the
// config, so every OTHER rule in the project goes unreported too. The
// error names the enum, which is what an author sees.
const result = scan(
project({ rules: { "no-eval": atLanguage("C#") }, sources: evalSource })
);
expect(result.status).toBeGreaterThan(1);
expect(result.stderr).toContain("SgLang");
});

it("treats Tsx and TypeScript as different parsers, not aliases", () => {
// The quiet half of the same field, and the reason the recipe names this
// pair specifically. `TypeScript` over a `.tsx` tree exits clean with no
// findings, which is indistinguishable from a codebase with nothing to
// flag — the rule looks written and proves nothing.
const sources = { "src/a.tsx": "const el = <div>{eval(x)}</div>;\n" };
const asTypeScript = scan(
project({ rules: { "no-eval": atLanguage("TypeScript") }, sources })
);
expect(asTypeScript.status).toBe(0);
expect(asTypeScript.stdout.trim()).toBe("");
expect(
scan(project({ rules: { "no-eval": atLanguage("Tsx") }, sources }))
.stdout
).toContain("eval(x)");
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] This describe("the language field", ...) block is a leftover duplicate of the one at line 526, not new coverage.

origin/fix/engine-capabilities (the base) already carries this exact suite (verified via git show origin/fix/engine-capabilities:packages/cli/test/ast-grep-vendor-contract.test.ts) — including the "accepts off-list aliases" case that's missing here:

  • Base (fix/engine-capabilities): one "the language field" describe with 3 tests (spelling rejection, off-list aliases, Tsx/TypeScript) — this is what's now at line 526 in the current file.
  • This PR's diff adds a second "the language field" describe (this one, 658-697) with 2 of those 3 tests, verbatim except the docstring now says create-sg-rule.txt instead of route.txt.

Commit ee1a034 ("drop the language-field suite now carried by the branch below") says its purpose was exactly to remove this kind of rebase leftover, but it only deleted the duplicate atLanguage const declaration (1 insertion / 4 deletions) — it missed this describe block. Both copies currently pass, so nothing catches them diverging on a future edit, and the "one canonical pin per binary guarantee" premise this file's own docstring states (lines ~17-24) is violated for this claim specifically.

Suggest deleting this block (lines 658-697) and keeping the one at 526-573, which is the fuller version (3 tests vs. 2).

Comment on lines +227 to +247
for (const entry of entries) {
if (!entry.startsWith(`${ruleId}-`) || !entry.endsWith("-test.yml")) {
continue;
}
let parsed: unknown;
try {
parsed = parse(await readFile(join(directory, entry), "utf8"));
} catch {
continue;
}
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
continue;
}
const buckets = parsed as Record<string, unknown>;
if (Array.isArray(buckets.valid)) validCount += buckets.valid.length;
if (Array.isArray(buckets.invalid)) invalidCount += buckets.invalid.length;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] fixtureCoverage counts a test file by filename glob only (${ruleId}-*-test.yml); it never checks the file's own id: field. sg test --filter ^${ruleId}$ (line 322), by contrast, resolves against the YAML's internal id:, as every test fixture built by this repo's own helpers does (see testFile/rule builders in ast-grep-vendor-contract.test.ts).

Concretely: a file named no-eval-scratch-test.yml sitting in no-eval's .tests/ directory (e.g. copy-pasted from a draft of a different rule, with id: left as no-alert-scratch and its own valid:/invalid: fixtures) would be counted by fixtureCoverage toward no-eval's coverage — flipping it to "both" — while sg test --filter ^no-eval$ never executes that file's cases at all, since ast-grep resolves test cases against a rule id and no-alert-scratch isn't no-eval. The gate reports ok: true for a rule whose invalid: bucket was never actually exercised — the exact class of gap this PR exists to close, reopened through the file-naming path rather than the empty-bucket path.

This is a narrow scenario (requires an author to misname/miscopy a test file's internal id: while keeping the correct filename prefix), but worth at least a code comment noting the assumption, or a cheap fix: also check buckets.id === ruleId before counting a file's buckets, mirroring what --filter actually keys on.

Comment on lines +217 to +230
const directory = ruleTestsDirectory(cwd, "sg", ruleId);
let entries: string[];
try {
entries = await readdir(directory);
} catch {
return "none";
}

let validCount = 0;
let invalidCount = 0;
for (const entry of entries) {
if (!entry.startsWith(`${ruleId}-`) || !entry.endsWith("-test.yml")) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] This re-readdirs ruleTestsDirectory(cwd, "sg", ruleId) and re-applies the identical entry.startsWith(${ruleId}-) && entry.endsWith("-test.yml") filter that validateRequirements already computed a few lines up (lines 172-175), inside the same verifyRule() call — runTestLayer only reaches fixtureCoverage when hasTestFile is already true (line 414). The predicate is written twice in the same file; a future change to the naming convention (new suffix, different prefix rule, etc.) has two call sites to update and nothing ties them together.

Not a hot-path concern (one extra syscall + filter per rule per run), but worth extracting a shared discoverRuleTestFiles(cwd, ruleId): Promise<string[]> used by both validateRequirements (for hasTestFile) and fixtureCoverage (for the bucket count), so the "what counts as this rule's test file" rule is stated once.

Comment on lines +658 to +697
/**
* How a wrong `language:` fails — the two shapes `create-sg-rule.txt` warns
* about where the field is written.
*
* Nothing of ours catches either one first: the vendored
* `src/generated/ast-grep-rule-schema.json` types `$defs.Language` as a bare
* string with no enum, and `verify` never reads the field. So the binary's
* response IS the contract, and a recipe telling an author what to expect is
* quoting it.
*/
describe("the language field", () => {
it("fails the whole scan on a spelling it does not recognize", () => {
// `C#` is the plausible wrong spelling of `CSharp`, and getting it wrong
// is not a rule that quietly matches nothing: ast-grep cannot parse the
// config, so every OTHER rule in the project goes unreported too. The
// error names the enum, which is what an author sees.
const result = scan(
project({ rules: { "no-eval": atLanguage("C#") }, sources: evalSource })
);
expect(result.status).toBeGreaterThan(1);
expect(result.stderr).toContain("SgLang");
});

it("treats Tsx and TypeScript as different parsers, not aliases", () => {
// The quiet half of the same field, and the reason the recipe names this
// pair specifically. `TypeScript` over a `.tsx` tree exits clean with no
// findings, which is indistinguishable from a codebase with nothing to
// flag — the rule looks written and proves nothing.
const sources = { "src/a.tsx": "const el = <div>{eval(x)}</div>;\n" };
const asTypeScript = scan(
project({ rules: { "no-eval": atLanguage("TypeScript") }, sources })
);
expect(asTypeScript.status).toBe(0);
expect(asTypeScript.stdout.trim()).toBe("");
expect(
scan(project({ rules: { "no-eval": atLanguage("Tsx") }, sources }))
.stdout
).toContain("eval(x)");
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] This describe("the language field", ...) block is a leftover duplicate of the one at line 526, not new coverage.

origin/fix/engine-capabilities (the base) already carries this exact suite at line 501 of that ref's copy of the file — including the "accepts off-list aliases" case that's missing here. Comparing the three ref's-eye views:

  • Base (fix/engine-capabilities): one "the language field" describe with 3 tests (spelling rejection, off-list aliases, Tsx/TypeScript).
  • This PR's diff: adds a second "the language field" describe with 2 of those 3 tests, verbatim except the docstring now says create-sg-rule.txt instead of route.txt.

Commit ee1a034 ("drop the language-field suite now carried by the branch below") says its purpose was exactly to remove this kind of rebase leftover, but it only deleted the duplicate atLanguage const declaration (1 insertion / 4 deletions) — it missed this describe block. Both copies currently pass, so nothing catches them diverging on a future edit, and the "one canonical pin per binary guarantee" premise this file's own docstring states (lines ~17-24) is violated for this claim specifically.

Suggest deleting lines 658-697 (or lines 526-573, whichever is meant to be the one true copy — the surrounding doc comment at 658-667 reads as the copy that should go, since it duplicates prose from 516-524 too).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants