Skip to content

feat(rules): hand ast-grep a rules-only tree, and add the fixture converter - #584

Merged
theCodeDrift merged 3 commits into
openspec/unified-rule-fixtures-1-specfrom
openspec/unified-rule-fixtures-2-verify
Oct 27, 2026
Merged

theCodeDrift merged 3 commits into
openspec/unified-rule-fixtures-1-specfrom
openspec/unified-rule-fixtures-2-verify

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Stack (root → tip):

Groundwork for unified rule fixtures. Everything here is correct on its own, below the layer that renames the fixture directory.

  • Rules-only ast-grep tree. assembleSgConfig copies each rule's <id>.yml into .taskless/.sg-rules/ and points ruleDirs there, so ast-grep never walks a rule's fixtures. On 0.51.0 a plain tests/ holding a .yml inside ruleDirs still fails the whole scan; the old .tests/ only escaped that through undocumented dot-directory skipping. check assembles against its snapshot, so the tree lands in the run directory.
  • buildOneRuleValeConfig. A Vale config enabling one rule under its own matchers, for the runner in feat(rules): every engine’s fixtures are case directories under tests/, judged through its own check #585.
  • fixture-convert.ts. The pure converter migration 12 and delivery will share: language → extension table, a path derived from the rule's scope and checked with path.matchesGlob (no warning on Node 22.22), sg snippets and Vale documents into case directories, and a legacy file-set converter. It is unused until feat(rules): every engine’s fixtures are case directories under tests/, judged through its own check #585.
  • Vendor pins. ast-grep 0.51.0: files: resolves against the scan's cwd when --config lives elsewhere; a plain tests/ inside ruleDirs fails the scan; every extension in the converter's table parses as its language. Vale 3.24.0: matchers resolve against the cwd, and hidden directories are walked. The sg test output pins are dropped, since nothing will call sg test.

Fixed after review: case ordinals restarted in each of a rule's legacy test files, so a rule with two dated test files had two fail-01s. Names are now claimed across a rule's conversions. A derived path also keeps the file's extension: a .txt Vale document under [*.md] and [*.txt] had been written as .md.

Second review round: a converted file keeps its extension or stays unconverted; delivery reserves names already in a served set and names moved files keep; sg test snapshots are dropped; Vale level values (warning, error) count as enabling; negated character classes convert.

Typecheck, lint and the full suite pass on this layer alone (2,786 tests).

Stack: #583 spec → #584 groundwork → #585 the tests/ rename → #586 recipes & archive. Every layer is green on its own; land it with gh stack merge. Design: openspec/changes/unified-rule-fixtures/design.md (archived on #586).

@theCodeDrift
theCodeDrift added this pull request to stack #587 October 26, 2026 16:07
@github-actions github-actions Bot added the Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state. label Oct 26, 2026
@theCodeDrift theCodeDrift changed the title feat(verify): judge every engine's fixtures as case directories through its own check feat(verify): judge every engine’s fixtures as case directories through its own check Oct 26, 2026
@theCodeDrift
theCodeDrift force-pushed the openspec/unified-rule-fixtures-2-verify branch from c5ceac8 to 62a16c4 Compare October 26, 2026 16:22
@theCodeDrift theCodeDrift changed the title feat(verify): judge every engine’s fixtures as case directories through its own check feat(rules): hand ast-grep a rules-only tree, and add the fixture converter Oct 26, 2026
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @theCodeDrift's task in 6m 5s —— View job


Review complete

  • Read prior review data (.prior-review.json) — no prior threads/reviews found; this is the first real review
  • Fetch PR diff via gh pr diff
  • Review assemble.ts changes (rules-only ast-grep tree)
  • Review engines.ts changes
  • Review fixture-convert.ts (new file)
  • Review test files
  • Post findings and final summary

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no reviewThreads and no reviews; the single entry under comments is the @claude /review trigger itself, not a prior finding to classify. So there is nothing previously-raised to carry forward — everything below is [New].


Findings

[New] Case numbering collides across a rule's multiple legacy test files, silently dropping fixtures — posted inline on packages/cli/src/rules/fixture-convert.ts (lines 374–385).

convertLegacyFileSet's sg branch converts each legacy .tests/*.yml file independently through convertSgTestFile, which numbers cases ${bucket}-01, ${bucket}-02, … starting fresh on every call. verify.ts explicitly supports a rule owning multiple test files ("across every test file the rule owns", verify.ts:474), so two legacy files each contributing a fail: entry will both land at tests/fail/fail-01/fail-01.<ext> — one silently overwrites the other, with no unconverted entry raised. This is dead code today (unused until #585/migration 12), but it's exactly the input migration 12 will feed it, so worth fixing before it's wired up.

[New] .taskless/.sg-rules is a new generated path that isn't gitignored anywhere

assembleSgConfig now writes a persistent copy tree at ASSEMBLED_SG_RULES (.taskless/.sg-rules). The doc comment on assembleSgConfig (assemble.ts:334-336) says plainly: "check assembles against its snapshot, so there the tree lands inside the run directory; it is gitignored for any other caller." That second half isn't true yet: .taskless/.gitignore's generated-paths list is GENERATED_PATHS = ["/.vale.ini", "/.sgconfig.yml"] in packages/cli/src/filesystem/migrations/0005-rule-directories.ts:35, and example/.taskless/.gitignore mirrors exactly those two entries — neither lists /.sg-rules. Today the only caller (scanProject) passes a disposable snapshot base, so this is latent, but the comment is written as a durable guarantee for whoever calls assembleSgConfig(cwd) directly next (verify in #585 is the obvious candidate). Worth adding /.sg-rules to GENERATED_PATHS (and the example fixture) in this layer, before a caller that writes it into the real project root exists.

Minor / non-blocking

What looked solid

  • The core mechanism — copying each rule's <id>.yml into a gitignored, rules-only .sg-rules/ tree and pointing ruleDirs there instead of relying on the undocumented dot-directory skip — is a real, well-justified improvement, and it's backed by new vendor-contract tests that actually reproduce the Cannot parse rule failure on a plain tests/ and prove the new config scans clean.
  • The files:/matcher cwd-resolution vendor-contract tests (ast-grep and Vale) are exactly the kind of pinning this design depends on for the future fixture runner, and they're well-targeted.
  • fixture-convert.ts's glob-to-path derivation (deriveFixturePath/pathFromGlob) is well-reasoned for a best-effort heuristic, and the test coverage for braces, globstars, and character classes is thorough.
  • Removal of the sg test-era pinning tests is consistent with the stated design (fixtures are no longer run via sg test).

Comment thread packages/cli/src/rules/fixture-convert.ts
@theCodeDrift
theCodeDrift force-pushed the openspec/unified-rule-fixtures-2-verify branch from 62a16c4 to c8ed830 Compare October 26, 2026 18:24
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

This is a follow-up review after the review-fix round (c8ed830, shared ClaimedCases and extension-preferring deriveFixturePath). The ordinal collision is fixed. Please focus on the areas below, where coverage is thin. Each comes with a reproducing input that was run against this branch.

  1. matchesGlob vs ast-grep's globset (fixture-convert.ts admitted). In ast-grep 0.51.0 a single * crosses /. In node:path it does not. Repro: files: ['src/**/*.ts'], ignores: ['*01.ts']. The converter admits src/fail-01.ts, but ast-grep scan does not scan that file at all. The fail case is caught loudly, but pass cases silently become vacuous passes. Nothing in ast-grep-vendor-contract.test.ts pins the glob dialect. Please also check the false negative src/[!_]*.ts (the class collapses to _, which the class itself excludes, so the case is left unconverted even though ast-grep admits it) and the same question for Vale's matcher dialect.

  2. The extension fallback in deriveFixturePath. When no candidate keeps the extension, it still returns one that does not, which contradicts its own docstring. Repro, Vale: scope [*.md] YES with document pass/a.txt is written as tests/pass/a/a.md, so plain text gets linted as Markdown. Repro, sg: language: TypeScript with files: ['src/**/*.ts','lib/*.js'] and ignores: ['src/**'] gives lib/fail-01.js. Should this be "unconverted" instead?

  3. Move-as-served paths bypass ClaimedCases (convertLegacyFileSet). Repro: Vale legacy files .tests/pass/a (no extension, so unconverted) and .tests/pass/a.md. The output holds both a file tests/pass/a and tests/pass/a/a.md, a file/directory collision when it is written. Please check every moveAsServed path against the claimed case names. Also check rest for an unconverted sg test file landing directly in tests/, and a rule served as <id>.yaml, which is reported as "not beside its rule".

  4. Scope and tree semantics. (a) valeFixtureScope reads only YES/NO. Does Vale treat r.r = error (a level) as enabling? If it does, such a rule derives no path. Is the root section, which is skipped, ever an enabling matcher? (b) assembleSgConfig now copies only <id>/<id>.yml and skips a missing file silently. Anything ruleDirs used to discover (a .yaml rule, extra rule files in a rule directory) now disappears from check without a report. Also confirm that no caller passes the real project root as cwd, so that the rm of .sg-rules can only touch a run snapshot.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @theCodeDrift's task in 6m 13s —— View job


Review complete

  • Read prior review data (.prior-review.json)
  • Fetch PR diff via gh pr diff
  • Investigate repro 1: matchesGlob vs ast-grep/Vale glob dialects
  • Investigate repro 2: extension fallback in deriveFixturePath
  • Investigate repro 3: moveAsServed vs ClaimedCases collisions
  • Investigate repro 4: Vale scope semantics + assembleSgConfig silent-skip
  • Post findings and final summary

Review mode: incremental — read 1 prior review thread before reviewing. The one thread (case-numbering collision across a rule's multiple legacy test files) is resolved — fixed in c8ed830 via the shared ClaimedCases set — so it is not repeated. Everything below is [New], in response to the four reproductions in this round's request. Posted as four inline comments on fixture-convert.ts; summarized here.

Findings

  1. Glob-dialect mismatch, both directions (admitted/pathFromGlob, lines 111–117 and 96–109). Traced the repro: files: ['src/**/*.ts'], ignores: ['*01.ts'] — matchesGlob's dialect never lets a separator-free pattern like *01.ts match a path with a / in it, so the converter admits src/fail-01.ts; globset (what ast-grep's files:/ignores: actually match against) matches * across / by default, so the real scan excludes it. A fail/ case catches this loudly; a pass/ case becomes a silent vacuous pass — verify can't distinguish "correctly didn't fire" from "never scanned." Separately confirmed the false-negative direction by tracing pathFromGlob: a negated class [!_] is collapsed to _ — the very character it excludes — so src/[!_]*.ts derives src/_fail-01.ts, which admitted() then correctly rejects, leaving a placeable fixture marked unconverted. Neither direction has test coverage today, and nothing pins the actual dialect either engine uses. (Vale's own matcher dialect: not independently verified here — worth a vendor-contract test rather than an assumption.)

  2. deriveFixturePath's extension handling still settles for the wrong extension, it doesn't just prefer the right one. Confirmed both named repros against the current code: [*.md] scope + pass/a.txt document derives "a.md" (no extension-preserving candidate exists, and the function returns the losing one rather than undefined); language: TypeScript + files: ['src/**/*.ts','lib/*.js'] + ignores: ['src/**'] derives "lib/fail-01.js" after the .ts candidate gets excluded. In both cases the written fixture is read by the wrong engine/language-filter, not merely sub-optimally named. Given the module's own stated reason this preference exists, this probably wants undefined (→ unconverted) when no extension-preserving candidate is admitted, not a fallback to one that isn't.

  3. moveAsServed bypasses ClaimedCases entirely. Traced the repro exactly: legacy .tests/pass/a (no extension → unconverted → moved to tests/pass/a, claimed untouched) followed by .tests/pass/a.md (converts to case "a" → tests/pass/a/a.md, since claimed never saw "pass/a" reserved). Output holds both a file at tests/pass/a and a file at tests/pass/a/a.md, an on-disk file/directory collision. The same gap applies to every other moveAsServed call in the function — none of them reserve or check against claimed.

  4. Vale scope semantics. Confirmed valeFixtureScope only recognizes literal YES/NO, while schemas/vale-config.ts's own vale-config-value-yes-no rejection states Vale treats a level value (e.g. error) as an enabling override. A matcher like [docs/**] / rule.rule = error therefore contributes to neither include nor exclude, and an otherwise-empty scope reads as "admit everywhere" — looser than Vale, not stricter, contradicting the module's stated invariant. convertLegacyFileSet calls this with no gate on validateValeRuleConfig passing first, so this is reachable by a served .vale.ini that never went through validation. Confirmed not a bug: the root section is correctly skipped, since the schema separately confirms Vale reads a root-level assignment as a run-level setting that enables nothing.
    For assembleSgConfig's silent skip: confirmed its only caller (scanProject → assembleEngineConfigs(plan.snapshot.base, …)) always passes the run's snapshot base, never the real project root — takeSnapshot always produces a separate directory, so the tree-clearing rm is bounded to a disposable snapshot as claimed. On the "extra rule files disappear" half: the layout table (layout.ts) hardcodes an sg rule's file as exactly <id>.yml and gives the sg engine no capturesDirectory, so there's no currently-supported layout where a rule directory legitimately holds more than that one file — this looks like a non-issue under the present schema rather than a live regression.

Comment on lines +111 to +117
/** Whether `path` is admitted by `scope`, in `node:path`'s glob dialect. */
function admitted(path: string, scope: FixtureScope): boolean {
const included =
scope.include.length === 0 ||
scope.include.some((glob) => matchesGlob(path, glob));
return included && !scope.exclude.some((glob) => matchesGlob(path, glob));
}

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.

[New] admitted checks node:path's glob dialect, which diverges from ast-grep's globset exactly where it matters: whether a single * crosses /.

globset (the Rust crate ast-grep's files:/ignores: are matched with) matches * across path separators by default — only literal_separator(true) would turn that off, and nothing pins which mode ast-grep actually scans with. node:path's matchesGlob, by contrast, documents * as explicitly excluding separators.

Repro: files: ['src/**/*.ts'], ignores: ['*01.ts'], case src/fail-01.ts.

  • admitted() here: matchesGlob('src/fail-01.ts', '*01.ts') is false (the pattern has no /, so under this dialect it can only match a bare filename) → the ignore never fires → the converter calls this admitted.
  • ast-grep's actual globset: if * crosses / as globset's default does, *01.ts matches src/fail-01.ts too → the real scan excludes the file.

For a fail/ case this just produces a loud, caught failure (no violation reported where one was expected). For a pass/ case it's silent: the file is never scanned at all, so "no violation" looks identical to "the rule correctly didn't fire." verify can't tell those apart from its own output.

Separately, pathFromGlob's negated-class handling looks like it has the opposite problem — a false negative. [!_]*.ts goes through .replaceAll(/\[!?([^\]])[^\]]*\]/g, "$1"), which for a negated class extracts the very character the class excludes: [!_] → _. So src/[!_]*.ts derives src/_fail-01.ts, and then admitted() correctly rejects its own derivation (the class literally excludes _), leaving a legitimately-placeable fixture marked unconverted. Substituting any character not in the excluded set (e.g. x) would derive a path the class actually admits.

Neither case is covered in fixture-convert.test.ts or pinned by a vendor-contract test today, so nothing currently catches either direction of this drift.

Comment on lines +119 to +148
/**
* A path for a fixture file named `stem.extension` that `scope` admits, or
* `undefined` when none of the derivations could be checked.
*
* A path keeping `.extension` is preferred over an earlier glob's that does
* not: both engines choose how to read a file by its extension, so under
* `[*.md]` and `[*.txt]` a `.txt` document written as `.md` would be linted
* as Markdown.
*/
export function deriveFixturePath(
scope: FixtureScope,
stem: string,
extension: string
): string | undefined {
const derived =
scope.include.length === 0
? [`${stem}.${extension}`]
: scope.include.map((glob) => pathFromGlob(glob, stem, extension));
const candidates = [
...derived.filter((path) => path.endsWith(`.${extension}`)),
...derived.filter((path) => !path.endsWith(`.${extension}`)),
];
return candidates.find(
(candidate) =>
candidate !== "" &&
!candidate.startsWith("/") &&
!candidate.split("/").includes("..") &&
admitted(candidate, scope)
);
}

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.

[New] The extension-fallback in deriveFixturePath still returns a wrong-extension candidate when it's the only admitted one — it doesn't just prefer the right extension, it will settle for the wrong one.

The ordering fix (c8ed830) tries every extension-preserving candidate before any non-preserving one, but candidates.find(...) still falls through to a non-preserving candidate rather than returning undefined. Traced two repros against the current code:

  • Vale, scope {include: ['*.md']}, document pass/a.txt: derived = ["a.md"] (only one glob, no extension-preserving option exists). candidates = ["a.md"], admitted("a.md", scope) is true, so this returns "a.md" — a .txt document gets written and then linted as Markdown, exactly the failure mode the docstring says this function exists to avoid.
  • sg, language: TypeScript (extension ts), files: ['src/**/*.ts', 'lib/*.js'], ignores: ['src/**']: "src/fail-01.ts" is derived first but excluded by ignores, so the search falls to "lib/fail-01.js", which is admitted. The snippet is written with a .js extension under a TypeScript-language rule. ast-grep selects a file's language from its extension before applying a rule's language: filter, so this file is classified as JavaScript and the TypeScript rule never considers it — same vacuous-pass / loud-fail-miss as the sibling glob-dialect finding above.

Given the module's own stated invariant ("both engines choose how to read a file by its extension"), a candidate that loses the extension isn't a degraded-but-usable fallback, it's actively wrong. This probably wants to return undefined (and let the caller mark it unconverted) when no extension-preserving candidate is admitted, rather than accepting one that silently changes which engine/language reads the file.

Comment on lines +406 to +419
const moveAsServed = (file: LegacyFile) => {
converted.push({
path: `${testsDirectory}/${file.path.slice(prefix.length)}`,
content: file.content,
});
};
const take = (conversion: Conversion, file: LegacyFile) => {
if (conversion.unconverted.length > 0) {
unconverted.push(...conversion.unconverted);
moveAsServed(file);
return;
}
converted.push(...conversion.files);
};

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.

[New] moveAsServed never touches claimed, so a moved-as-served path can collide with a case directory a later conversion in the same rule claims.

Traced against the current code with: legacy Vale files .tests/pass/a (no extension) and .tests/pass/a.md, same rule, in that order.

  • .tests/pass/a → convertValeDocument sees extension === "" → derived = undefined → unconverted → moveAsServed writes { path: "tests/pass/a", ... }. claimed is untouched.
  • .tests/pass/a.md → convertValeDocument(scope, "pass", "a.md", ..., claimed) → stem "a", and since claimed has no "pass/a" entry (the move above never added one), claimCase happily hands out "a" → writes { path: "tests/pass/a/a.md", ... }.

The output set now holds both tests/pass/a (a file) and tests/pass/a/a.md (which requires tests/pass/a to be a directory) — a file/directory collision that only surfaces when something actually writes these paths to disk.

This is the general shape of the gap: every moveAsServed call (here, and the two unconverted+moveAsServed pairs above for the "not under pass/fail" and "not beside its rule" cases) places a file at a path derived independently of claimed, so it's never reserved and never checked against what the case-directory conversions are about to claim. Worth either claiming the moveAsServed destination in claimed before conversion proceeds, or deriving moved-as-served paths from a namespace that can't overlap with <bucket>/<case>/.

Comment on lines +307 to +328
/**
* The scope a Vale rule's `.vale.ini` declares: every matcher assigning
* `<id>.<id> = YES` admits, every one assigning `NO` excludes.
*
* Positional precedence is flattened, deliberately. A path is admitted when
* some enabling matcher matches it and no disabling one does, which is
* stricter than Vale and so never derives a path Vale would exclude.
*/
export function valeFixtureScope(ruleId: string, config: string): FixtureScope {
const key = `${ruleId}.${ruleId}`;
const scope: FixtureScope = { include: [], exclude: [] };
for (const section of parseValeRuleConfig(config).sections) {
if (section.name === undefined) continue;
for (const node of section.nodes) {
if (node.kind !== "property" || node.key !== key) continue;
const value = node.value.trim().toUpperCase();
if (value === "YES") scope.include.push(section.name);
if (value === "NO") scope.exclude.push(section.name);
}
}
return scope;
}

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.

[New] valeFixtureScope reads only literal YES/NO, but the config schema (schemas/vale-config.ts's vale-config-value-yes-no rejection) says plainly that Vale treats any other value as a level override that still enables the rule: "Vale {VERSION} reads a level name here as an override of the style's own level, and anything else as off, without saying so."

So for a matcher like [docs/**] / hedge.hedge = error, Vale enables hedge under docs/**, but valeFixtureScope registers neither an include nor an exclude for that matcher (the value matches neither "YES" nor "NO"). With scope.include left empty, deriveFixturePath reads that as "no restriction, admit everywhere" (scope.include.length === 0 branch) — which is the opposite of "stricter than Vale, never derives a path Vale would exclude" that this module's docstring claims. A fixture derived this way lands at the bare stem (e.g. a.md) instead of under docs/**, i.e. outside the one matcher that actually scopes the rule.

This is reachable today: convertLegacyFileSet's vale branch calls valeFixtureScope(ruleId, sibling(".vale.ini") ?? "") directly on the served .vale.ini text, with no gate on validateValeRuleConfig passing first. If migration 12 or delivery ever calls this converter before (or instead of) validation — which is plausible, since convertLegacyFileSet is documented as usable by "delivery, which reads a served set in the legacy layout" — a level-value rule's fixtures get silently placed outside its real scope.

Separately, confirmed as not a bug: the root section (no name) is correctly skipped — the same schema rejects a root-level assignment as a run-level key that Vale reads as enabling nothing, so the root is never an enabling matcher.

…ep each file's extension

A rule improved more than once owns several dated ast-grep test files, and each
numbered its snippets from one, so the second file's fail-01 landed on the
first's. Case names are now claimed in a set shared across a rule's
conversions; two Vale documents sharing a stem get <stem> and <stem>-2.

Path derivation also preferred the first glob even when it changed the file's
extension, so under [*.md] and [*.txt] a .txt document became .md and would
be linted as Markdown. A path keeping the extension now wins.
…ing a path

From the follow-up review round:
- A derived path keeps the file's extension or there is none: under [*.md]
  a .txt document was written as .md and linted as Markdown. A wildcard-only
  last segment (src/*, [*]) takes the file's extension.
- Delivery's conversion claims every case the served set already carries and
  every name a moved fixture keeps, so a converted case no longer merges into
  an existing fail-01 or lands on a moved file's path.
- sg test's __snapshots__ are dropped and named, not moved into tests/.
- Vale enables a rule with a level as well as YES, so both admit.
- A negated character class is filled with a character it admits.
@theCodeDrift
theCodeDrift force-pushed the openspec/unified-rule-fixtures-2-verify branch from c8ed830 to 2db039a Compare October 26, 2026 19:03
@theCodeDrift
theCodeDrift merged commit 6ccc740 into main Oct 27, 2026
12 checks passed
@theCodeDrift
theCodeDrift deleted the openspec/unified-rule-fixtures-2-verify branch October 27, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant