fix: validate an sg rule's language field in verify - #173
Conversation
The vendored ast-grep rule schema types `language:` as a bare string with no enum and `verify` never read it, so any spelling returned `ok: true` and the binary was the first thing with an opinion — at `check` time. An unrecognized name aborts config parsing and takes every other sg rule down with it; a recognized name for the wrong parser reports nothing and reads as a clean codebase. `verify` now resolves the field the way ast-grep does: case-insensitively, against the canonical list plus the sixteen aliases the binary accepts. An unresolvable name is an error naming the accepted spellings; a resolvable but off-list one is a notice naming the canonical spelling, so the lowercase `typescript` rules already in the wild keep passing. The Tsx/TypeScript split is checked against `files:`, which is the form that trap takes in practice. The alias table is the one list `sg run -h` will not enumerate, so it is pinned by probing: the vendor contract feeds every alias to the binary through a real config and reads the resolution back out of the scan stream's own `language` field, and sweeps a set of near-misses to catch a bump that adds one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
|
@claude /review |
|
Claude finished @thecodedrift's task in 4m 11s —— View job Review mode: incremental — read 0 prior review thread(s) before reviewing.
SummaryThis is a careful, well-documented fix: I found one real gap, posted inline:
Everything else — the alias table, the case-insensitive resolution, the deliberate no-trim behavior, the "leave missing |
| const files = ruleData.files; | ||
| if (own !== undefined && Array.isArray(files)) { | ||
| const sibling = own === "ts" ? "tsx" : "ts"; | ||
| const siblingLanguage = own === "ts" ? "Tsx" : "TypeScript"; | ||
| const named = new Set( | ||
| files | ||
| .filter((glob): glob is string => typeof glob === "string") | ||
| .flatMap((glob) => globExtensions(glob)) |
There was a problem hiding this comment.
[New] The files: scan silently misses the object form of RuleFileGlob, which is exactly the shape it's meant to guard.
The vendored schema (src/generated/ast-grep-rule-schema.json, $defs.RuleFileGlob) allows each entry in files: to be either a plain glob string or an object { glob: string, caseInsensitive?: boolean }. assemble.ts passes files through unmodified, so ast-grep sees the object form as-is.
Here, though:
const named = new Set(
files
.filter((glob): glob is string => typeof glob === "string")
.flatMap((glob) => globExtensions(glob))
);non-string entries are dropped before extracting extensions. A rule like:
language: TypeScript
files:
- { glob: "src/**/*.tsx" }produces an empty named set, so named.has(sibling) is false and neither the error nor the notice fires — the exact "recognized name, wrong parser" trap this whole feature exists to catch (per the PR description's sg/wrong-parser example) goes undetected whenever the rule author uses the object form instead of a bare string. It's a silent false negative, not a crash, but it's the one shape a RuleFileGlob object can legally take that the check doesn't handle at all.
Worth at least unwrapping { glob } entries (glob.glob) before calling globExtensions, mirroring what ast-grep itself does.
What
taskless verifyreturnedok: truefor anylanguage:spelling. The vendoredast-grep-rule-schema.jsontypes$defs.Languageas a bare string with no enum(its only hint is an
examplereading"typescript", which is not even thecanonical spelling), and
verifynever read the field — so the ast-grep binarywas the first thing with an opinion, at
checktime, in both of its bad ways:SgLangdeserialization fails, which aborts parsing ofthe single config Taskless assembles per run. One typo blinds the whole sg
engine.
TsxandTypeScriptare two parsers, notaliases. A
TypeScriptrule scoped to**/*.tsxmatches nothing, exits zero,and is indistinguishable from a clean codebase.
verifynow resolveslanguage:the way ast-grep does and reports both.Decision 1 — case variants are accepted, with a notice
Accept, and name the canonical spelling in a notice. Hard rejection was the
other option and it is a breaking change for rules that demonstrably work:
onboarding this repository produced four rules spelled
typescript, and thatlowercase spelling is the one ast-grep's own JSON Schema shows as the field's
example. Failing them would turn a green project red over a spelling theengine itself resolves.
Measured against the pinned 0.41.0 binary, through a real rule config:
TypeScript,Tsx,Cpp, …typescript,TYPESCRIPT,Cs,GOLANG,JsXts,tsx,js,jsx,py,rb,rs,kt,hs,ex,sol,cc,cxx,c++,cs,yml,golangC#,nonsense,h,hpp,mjs,cjs,sh,tf,csx,htmdid not match any variant of untagged enum SgLang"ts "Two of those rows changed the implementation.
golangis a real alias and wasmissing from the first draft of the table. And because
"ts "is rejected bythe binary,
resolveAstGrepLanguagedeliberately does not trim: a trimmingresolver would pass a rule ast-grep refuses to load.
One more measured surprise worth recording: a rule's
language:field andsg run --langdo not share a vocabulary.--lang C++is rejected outrightwhile
language: C++parses fine. Everything here was probed through a config,because that is the only thing a rule file is ever fed to.
Decision 2 — where the list comes from
AST_GREP_LANGUAGESwas already derived-by-pinning: set-equality againstsg run -h, so a version bump that adds or drops a language fails loudly.The alias table cannot work that way —
sg run -hprints only the canonicallist, and nothing in the binary enumerates aliases.
capabilities.tsis also aWorker-safe pure-data module (the build fails if the prompts chunk reaches a
host capability), so it cannot spawn the binary itself.
So the aliases are transcribed there and pinned by probing in
test/ast-grep-vendor-contract.test.ts:back out of the scan stream's own
languagefield. That field names theparser ast-grep settled on, so the mapping is the binary's answer rather than
ours inferred from which files got scanned.
verifyrelies on.h,hpp,mjs,cjs,sh,tf,csx) assertsrejection, which is the direction the table cannot self-check: a bump that
adds an alias would otherwise leave
verifyfailing a rule that now works.Those probes key on
SgLangin stderr, not on the exit status. Every rule thatfails to load exits 8 with the same top line, and
pattern: zzzis legitimatelyunparseable in some grammars (
Htmlwants akind) — so status alone cannottell "not a language" from "not a pattern".
Measured output
pnpm build, thenverifyagainst deliberately broken rules:sg/no-evalis this repository's own rule: the failure there is the missing testfile that
mainalready has, and the new line is the notice — the exact case theissue reported as invisible.
Notes on the issue text
The issue says ast-grep "accepts case variants (
cppandC++both reachCpp)".C++is not a case variant ofCpp— it is a separate alias, and thetwo live in different places in the fix. The accepted vocabulary is the
canonical list compared case-insensitively, plus sixteen aliases, which is a
wider set than the issue implies.
The issue's "related smaller item" — warning when a
TypeScriptrule'sfiles:globs reach
.tsx— is included, since it is the only form the wrong-parser traptakes that is checkable from the rule file alone. It is an error when every
glob names the sibling extension (the rule is entirely dead) and a notice when
only some do (half the scope still reports real findings).
Checks
pnpm typecheck,pnpm lint,pnpm test(843 tests) all pass.Fixes #165