Skip to content

test(cli): pin the two metavariable-binding fixes, and warn about inert rules - #199

Merged
thecodedrift merged 2 commits into
mainfrom
feat/184-matching-semantics
Aug 27, 2026
Merged

test(cli): pin the two metavariable-binding fixes, and warn about inert rules#199
thecodedrift merged 2 commits into
mainfrom
feat/184-matching-semantics

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Verifies that the 0.41.0 to 0.45.2 upgrade did not change what rules match, and pins the two cases where it did.

#182 verified the interface did not move. Nothing had ever checked the semantics: a valid, unchanged rule matching a different set of nodes, with no error and no warning.

Going to the upstream PRs was the whole result

My first corpus was hand-written from the changelog: rules over nthChild, negated not, and root metavariables. Run against both binaries, every construct produced non-empty and identical results. That looks like evidence the upgrade was safe. It was not evidence of anything.

Neither bug fires unless a metavariable is bound in the exact position the fix changed. Reading the actual pull requests produced reproducing cases immediately, and both show a real difference:

#2677nthChild's ofRule reused one environment across siblings, so the first match committed $S and every later sibling failed the consistency check and went uncounted.

result
0.41.0 matched nothing
0.45.2 matches b();

#2676 — a not passed the live environment to its inner matcher, so a candidate that matched (and therefore failed the negation) left its binding behind.

rendered message: $A
0.41.0 foo — leaked from a return foo; the negation had rejected
0.45.2 empty — correctly unbound

The second case breaks this issue's own comparison

That finding is identical across the upgrade: same file, same range, same rule. Only the binding differs.

#184 specifies comparing findings as sets of (file, range, ruleId). That comparison would have reported no change here. The test therefore asserts on the rendered message, and any future differential needs to compare bindings, not just locations.

Pinned in the vendor contract, not as a differential harness

The tests assert what the vendored binary does, so they need no second binary, no network, and no skip condition. A future bump that regresses either behaviour turns them red. That is the durable half of what #184 asked for; a two-binary harness remains useful for the next upgrade and is not built here.

Not pinned

#2868, root metavariables against comments. I could not reproduce a difference in any shape I tried, including the TSX case the PR names. Rather than assert something I have not observed, it is left out and recorded here.

Two gotchas for create-sg-rule

Both cost real time while building this, and both were written down nowhere. Each parses, verifies, runs, exits 0, and reports nothing forever, which reads exactly like a clean codebase:

  • follows and precedes need stopBy: end to cross punctuation. Between two array elements sits a ,, which is itself a node, so the previous element is not the previous sibling. Measured: [10, 10, 30] with a bare follows matches nothing; with stopBy: end it matches.
  • A not containing a bare metavariable excludes everything. $A bound to a name node is its own descendant, so a not searching the same subtree finds it in every candidate and rejects them all. The negation has to be scoped to the part you meant.

Verification

pnpm typecheck, pnpm lint, 966 tests, pnpm cli check exit 0.

Refs #184
Refs #162

thecodedrift added a commit that referenced this pull request Aug 27, 2026
…watch for

The 0.11.x ledger's hardest section said "re-run its fixtures and read the
findings rather than the exit code", which tells an author to look without
saying what for. Measuring both shapes against the two binaries (#199) turned
each into an instruction.

A rule using `nthChild` with a binding `ofRule` was INERT at 0.41.0 and now
reports. The rule is not broken, it never ran, so its findings on this version
have never been seen and may be numerous on a codebase that was never actually
checked against it. Fixtures cannot surface this: a rule that matched nothing
passed the `pass/` side of its own tests. A non-binding `ofRule`, `kind:` or
`regex:`, is unaffected, which is the check that tells an author whether their
rule is in scope.

A metavariable bound inside a negated `not` and referenced in `message`, `fix`
or a constraint now renders empty, because a `not` contributes no bindings and
the old value was leaked from the candidate the negation REJECTED. This one
changes no counts at all: same file, same range, same rule, different rendered
output. So the instruction is to grep for a metavariable appearing both inside
a `not` and in a `message` or `fix`, and the consequence is worth stating
plainly, since a `fix` in that shape has been writing leaked text into files.

Root metavariables and comments is named as unreproduced rather than dressed
up as guidance, because no shape we tried showed a difference.

Refs #180
Refs #184
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 1m 44s —— View job


Review: test(cli): pin the two metavariable-binding fixes, and warn about inert rules

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no reviews, no review threads, and no comments besides the triggering @claude /review, so this is the first review of this PR — nothing to mark as already addressed.

Todos

  • Read .prior-review.json (empty — first review)
  • Read the full diff (git diff origin/main...HEAD) across all 3 changed files
  • Trace test helpers/assertions against the vendored ast-grep schema and existing conventions
  • Check CLAUDE.md / STYLEGUIDE-CODE.md compliance
  • Post inline comments and this summary

Findings

[New] Worth a maintainer double-check: possible off-by-one in the nthChild pin (packages/cli/test/ast-grep-vendor-contract.test.ts:1023-1032)
This repo's own vendored schema (packages/cli/src/generated/ast-grep-rule-schema.json, NthChildSimple) documents the plain-number form of position as "a number indicating the precise element index" with minimum: 0 — i.e. 0-indexed. Under that reading, position: 2 over the three ofRule-matching siblings a(); b(); c(); should select the 3rd match (c();), not the 2nd (b();) the test asserts. I couldn't run the vendored sg binary in this environment (no node_modules, network install blocked) to settle whether the real implementation is actually CSS-style 1-indexed instead, so this isn't a confirmed bug — but it's a concrete, checkable question worth resolving before trusting this pin, since it's specifically guarding a subtle metavariable-binding regression (ast-grep/ast-grep#2677). Left as an inline comment on the exact lines.

[New] Minor duplication, non-blocking (packages/cli/test/ast-grep-vendor-contract.test.ts:288-314)
semanticsFindings/semanticsRule duplicate the pre-existing arityRule/arityMatches YAML-builder + stdout-parsing pattern almost exactly, differing only in the message: line and one extra parsed field. Not a correctness issue, just a maintenance-cost note. Left as an inline comment.

[New] Minor efficiency, not flagged inline (packages/cli/test/agent-extensions.test.ts:462-493)
The three new it blocks under "create-sg-rule warns about silently inert relational rules" each independently spawn the full CLI process (runCli) against identical output just to check a different substring each time — could be one invocation with three expects. This matches the file's existing (already-unfactored) convention of one runCli call per assertion, so it's an extension of current style rather than a new regression; not worth blocking on.

What I checked and found clean

  • Build-output-testing rule (STYLEGUIDE-CODE.md): semanticsFindings's JSON-line parsing of scan(...).stdout is not a violation — this file's own header explains it invokes the vendored ast-grep binary directly rather than through the project's own wrapper, precisely so tests assert against ast-grep's real behavior rather than the project's interpretation of it. That's the "spawns the built CLI, asserts on its output" case the guide calls out as fine, not the "reconstruct a fact the generator already had" case it forbids.
  • The not/stopBy YAML examples added to create-sg-rule.txt:220-266 are internally consistent — traced the has/field/not composition against the vendored schema and TS grammar fields; the "why this matches nothing, why the fix works" narrative holds up.
  • The second pinned regression (not binding leak, fix: don't leak metavar bindings from a negated not rule ast-grep/ast-grep#2676, lines 1035-1061) is structurally sound against the schema (stopBy: end default, Relation shape) and, unlike the first, doesn't depend on an ambiguous indexing convention.
  • Import ordering, library-types-over-custom-interfaces, barrel exports, DurableObjectRPC — none apply; no new imports or interfaces added in this diff.
  • No functional code paths were touched (verify.ts, verify-examples.ts untouched) — this PR is test + prompt-doc only, matching its stated scope. The PR body's own scoping decisions (not pinning #2868, not building a full differential harness) are disclosed tradeoffs, not defects.

Per instructions, I did not run pnpm build/pnpm typecheck/pnpm lint/tests locally or check CI status — that's CI's job and will report on the PR itself.

Comment thread packages/cli/test/ast-grep-vendor-contract.test.ts
Comment thread packages/cli/test/ast-grep-vendor-contract.test.ts
thecodedrift added a commit that referenced this pull request Aug 27, 2026
…watch for

The 0.11.x ledger's hardest section said "re-run its fixtures and read the
findings rather than the exit code", which tells an author to look without
saying what for. Measuring both shapes against the two binaries (#199) turned
each into an instruction.

A rule using `nthChild` with a binding `ofRule` was INERT at 0.41.0 and now
reports. The rule is not broken, it never ran, so its findings on this version
have never been seen and may be numerous on a codebase that was never actually
checked against it. Fixtures cannot surface this: a rule that matched nothing
passed the `pass/` side of its own tests. A non-binding `ofRule`, `kind:` or
`regex:`, is unaffected, which is the check that tells an author whether their
rule is in scope.

A metavariable bound inside a negated `not` and referenced in `message`, `fix`
or a constraint now renders empty, because a `not` contributes no bindings and
the old value was leaked from the candidate the negation REJECTED. This one
changes no counts at all: same file, same range, same rule, different rendered
output. So the instruction is to grep for a metavariable appearing both inside
a `not` and in a `message` or `fix`, and the consequence is worth stating
plainly, since a `fix` in that shape has been writing leaked text into files.

Root metavariables and comments is named as unreproduced rather than dressed
up as guidance, because no shape we tried showed a difference.

Refs #180
Refs #184
…rt rules

The 0.41.0 to 0.45.2 upgrade changed what some valid rules match, with no
error and no warning, and nothing verified it. #182 checked the interface;
this checks the semantics.

Both cases are taken from the upstream pull requests rather than written from
the changelog, and that distinction is the whole result. Hand-written rules
over the same features, nthChild and negated not and root metavariables, showed
NO difference between the two binaries: neither bug fires unless a
metavariable is bound in the exact position the fix changed. A corpus built by
guessing would have compared non-empty results, agreed, and been reported as
evidence that nothing moved.

Measured against both binaries:

ast-grep/ast-grep#2677. `nthChild`'s `ofRule` reused one environment across
siblings, so the first match committed `$S` and every later sibling failed the
consistency check and went uncounted. At 0.41.0 the rule matched NOTHING; at
0.45.2 it matches `b();`.

ast-grep/ast-grep#2676. A `not` passed the live environment to its inner
matcher, so a candidate that matched, and therefore failed the negation, left
its binding behind. At 0.41.0 `$A` rendered as `foo`, leaked from a
`return foo;` that the negation had rejected; at 0.45.2 it is unbound.

The second one is asserted on the MESSAGE, not the match, and that is worth
keeping: the finding is identical across the upgrade. Same file, same range,
same rule. Only the binding differs. #184 specifies comparing findings as sets
of (file, range, ruleId), and that comparison would have reported no change
here.

ast-grep/ast-grep#2868, root metavariables against comments, is NOT pinned. I
could not reproduce a difference in any shape I tried, including the TSX case
the PR names, so there is nothing honest to assert yet.

`create-sg-rule` also gains the two ways a relational rule verifies clean and
reports nothing forever, both of which cost real time here and were written
down nowhere: a sibling relation needs `stopBy: end` because the separators
between siblings are nodes too, and a `not` searching the subtree that bound
the metavariable always finds it, so it rejects everything.

Refs #184
Refs #162
From review of #199, which asked whether `position: 2` really selects the
second match. It was right to ask: the vendored schema describes the
plain-number form of `nthChild` as "A number indicating the precise element
index" with `minimum: 0`, which reads as 0-indexed and would make the
binding test off by one, selecting `c();` while claiming `b();`.

Measured against the binary. Position 0 matches nothing at all, and 1, 2, 3
select the first, second and third sibling. The behaviour is CSS-like and the
schema's own description is misleading.

Pinned rather than answered in a comment, because the schema will keep
suggesting otherwise to the next reader, and because the ordering the
metavariable-binding test depends on should be asserted rather than assumed.
A future bump that changes the indexing now turns this red instead of quietly
inverting what that test proves.
@thecodedrift
thecodedrift force-pushed the feat/184-matching-semantics branch from 2ab5738 to 8155a56 Compare August 27, 2026 06:52
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Review: test(cli): pin the two metavariable-binding fixes…"
#199 (comment)

All three addressed. Threads replied to and resolved.

The position: 2 question was the valuable one, and it produced a test. The reading was correct: NthChildSimple in the vendored schema really does say "A number indicating the precise element index" with minimum: 0, which is 0-indexed. Under that reading the binding test would have selected c(); while claiming b();, silently validating the wrong invariant while still passing.

Measured against the binary:

position selects
0 nothing at all
1 a();
2 b();
3 c();

The assertion holds and the schema's own description is the misleading part. Pinned as its own case in 8155a56 rather than answered in a comment, since the schema will keep suggesting otherwise to the next reader and the ordering the binding test depends on should be asserted rather than assumed.

The duplication finding is declined, deliberately. arityMatches parses only text; semanticsFindings also parses message, because one of the behaviours it pins is invisible in the match and appears only in the rendered output. Merging them means a parameter whose whole job is telling two callers apart. The shared part is a five-line YAML scaffold, and a change to it fails loudly in both tests rather than silently in one.

pnpm typecheck, pnpm lint, 973 tests, pnpm cli check exit 0.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit f4d56ed into main Aug 27, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the feat/184-matching-semantics branch August 27, 2026 06:57
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.

1 participant