feat(cli): share the platform-binary resolver, add the Vale binary spec - #71
feat(cli): share the platform-binary resolver, add the Vale binary spec#71thecodedrift wants to merge 15 commits into
Conversation
12dded7 to
c452427
Compare
A spec-only proposal is its own tip until its implementation is stacked on top, so the gate demanded it archive a change nobody had built yet. #70, #71, and #72 have failed this check on every run since July 28 for exactly that reason — weeks of red on PRs that were never merge-eligible, which is how a team learns to stop reading red. A draft cannot merge, so skipping it costs nothing: no unarchived change can reach `main` either way. `ready_for_review` is added to the trigger types because the default set (opened/synchronize/reopened) does not include it — without that, a draft could be marked ready and merged on a green that was never re-evaluated. That event is what preserves the guarantee. The tip rule is unchanged for PRs that are ready for review.
c452427 to
7b4d143
Compare
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.
The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.
6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.
6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.
Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.
7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
There was a problem hiding this comment.
Pull request overview
Adds a new OpenSpec change (openspec/changes/add-vale-rule-engine/) that specifies how the CLI should support Vale as a second static-tier rules engine, including engine routing, concurrent execution/merge semantics, and an engine-selection help topic.
Changes:
- Introduces requirements for running Vale with committed config, mapping Vale JSON findings into
CheckResult, and handling missing binary/timeout cases. - Specifies per-rule scoping via
.vale.inimatchers and fixture-based Vale rule verification (pass//fail). - Adds proposal/design/tasks documentation and OpenSpec metadata for the change.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| openspec/changes/add-vale-rule-engine/.openspec.yaml | Declares OpenSpec change metadata (schema + created date). |
| openspec/changes/add-vale-rule-engine/proposal.md | Summarizes motivation, scope, impact, and delivery shape for adding Vale + engine-selection topic. |
| openspec/changes/add-vale-rule-engine/design.md | Captures key design decisions (scoping, layout, binary resolution strategy, mapping contract). |
| openspec/changes/add-vale-rule-engine/tasks.md | Implementation task checklist for Vale engine execution, orchestration, and help topic registration/tests. |
| openspec/changes/add-vale-rule-engine/specs/cli-vale-rule-engine/spec.md | Requirements for Vale execution model, config usage, result mapping, timeout, missing-binary behavior, and verify fixtures. |
| openspec/changes/add-vale-rule-engine/specs/cli-rule-routing/spec.md | Requirements for the engine-selection knowledge topic and its constraints (distinct from route/trust tier). |
| openspec/changes/add-vale-rule-engine/specs/cli-rule-format/spec.md | Requirements for Vale style layout under .taskless/vale/rules/ and rules. prefix normalization. |
| openspec/changes/add-vale-rule-engine/specs/cli-help/spec.md | Requirements to register engine-selection in help + add cross-references from route/static. |
| openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md | Requirements for concurrent per-engine execution and merged results semantics. |
Suppressed comments (1)
openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md:18
- This spec ends with a "## MODIFIED Requirements" heading but contains no modified requirements. Empty sections can confuse reviewers and can break tooling that expects content under each header.
## MODIFIED Requirements
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| ## 1. Vale engine | ||
|
|
||
| - [ ] 1.1 Extract the platform-binary resolution in `findSgBinary()` (`rules/scan.ts:38-61`) into a shared helper — resolve `<pkg>/package.json` via `createRequire(import.meta.url)`, exec the binary beside it, fall back to `PATH` — and use it for both `sg` and `vale`. Parameterize the package name rather than reusing ast-grep's: `add-vale-binary-packages` ships `@taskless/vale-<os>-<cpu>` with no libc suffix, so the `-gnu` mapping `findSgBinary()` applies to every Linux must not be applied to the Vale lookup. When nothing resolves, report the Vale engine unavailable without aborting other engines (D6b) |
|
|
||
| ## 3. Engine-selection knowledge topic | ||
|
|
||
| - [ ] 3.1 Author `packages/cli/src/help/<engine-selection>.txt` from the seed prose in `tmp/SEED-engine-selection-prose.md`: the three engine definitions (`sg` in-file syntax tree incl. relational correlation, `vale` prose/markup, `runtime` cross-file/graph/metadata/normalization), the reason-before-answer procedure, and the worked example table |
|
|
||
| Two separable concerns. **Resolution** — where the CLI looks — is settled here. **Delivery** — how the binary arrives — is `add-vale-binary-packages`, a prerequisite of this change. | ||
|
|
||
| Resolution follows the pattern **already in production for ast-grep**: `findSgBinary()` (`rules/scan.ts:38-61`) resolves the platform package from our own module context with `createRequire(import.meta.url).resolve('<pkg>/package.json')`, execs the binary beside it, and falls back to `PATH`. Vale gets the same shape; the two should share one helper rather than diverge. |
16f518f to
c69649c
Compare
…ngine Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdEhGzeQfSGJM3nKKNj7Bp
The ingest writer hardcodes .taskless/rules, so 0004 would relayout existing rules under sg/ while the next `rule create` wrote back into a directory no engine dispatches from. The API carries no engine discriminator, so an engine-less payload is ast-grep by definition — the default is permanent, matching what the migration does to the same rules on disk. An unrecognized engine fails loudly instead, since filing a Vale rule under sg/ surfaces as a broken rule rather than version skew. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…solution Three additions to the Vale change: The engine-selection knowledge topic ships here rather than as a follow-on, since it names the engine directories this change creates. Written engine-shaped, kept distinct from route's authoring destination and from trust tier — sg and vale are both static-tier, and conflating those axes is what the platform's binary classifier got wrong. Legacy .taskless/rules/ stays dispatchable alongside sg/rules/, so a producer can keep using the pre-migration layout and its rules keep running. That decouples this release from any consumer's. Vale's binary resolves the way findSgBinary already does — resolve the platform package from our own module context and exec beside it, no lifecycle script. That code exists because the upstream postinstall hardlink breaks under pnpm dlx, leaving a placeholder text file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
An OpenSpec change has to land complete in one PR — the archive gate requires a tip PR to leave openspec/changes/ empty — and this one had grown to 44 tasks across ten groups. The layout half moved to partition-rules-by-engine. What stays is the engine and the knowledge about it: the Vale runner and its scoping model, fixture verify, findings mapping, concurrent multi-engine orchestration, and the engine-selection topic. 22 tasks. The topic stays here rather than with the layout because it names sg/vale/runtime as things an agent can actually choose between, which is only true once Vale executes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Every one of these PRs carried skip-changeset while it was spec-only, which becomes wrong the moment implementation lands. Stating the impact in the proposal means the tip PR needs a changeset written, not a label kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…s ship add-vale-binary-packages published all six @taskless/vale-<os>-<cpu> packages and pinned them as optionalDependencies, so tasks 1.1b-1.1e (publishing, the mirror CI job, the musl decision, and the Vite externalization check) are done elsewhere. Section 1 reduces to the runtime resolution in task 1.1, which now carries the one detail the removed subtasks held: the Vale packages have no libc suffix, so the shared helper must not apply findSgBinary()'s -gnu mapping to them. Closes task 7.3 of add-vale-binary-packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
c69649c to
32dce95
Compare
…ed path 0.1 and 0.2 both hold now: partition-rules-by-engine is archived on main as 2026-08-02-partition-rules-by-engine, and all six @taskless/vale-* packages are published at 3.17.1-20260810052605 and pinned in packages/cli optionalDependencies. Record what satisfies them rather than leaving the boxes open for someone to re-derive. Task 3.1 sourced the topic from tmp/SEED-engine-selection-prose.md, which no longer exists. Treating a missing file as a prerequisite would block the unit on recovering it; the content requirements are already stated in 3.1-3.4, so name those as the authority and rebuild the examples. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ng ref
tasks.md deferred the export decision to "D9", which does not exist — the
design records D1-D7 and the reasoning lives in D7. Point at D7.
The conditional it carried ("whichever of this change and
export-knowledge-prompts lands second adds the entry") has resolved:
export-knowledge-prompts landed first, so the entry falls here. Restate it as
fact and add task 3.7 for it.
3.7 is not bookkeeping. TOPICS union INTERNAL_TOPICS must account for every
canonical recipe on disk, and test/prompts.test.ts asserts that in both
directions, so adding help/<engine-selection>.txt without classifying it turns
the suite red. The registration therefore belongs in the same unit as the topic
file rather than a later one.
D7 now records why TOPICS rather than INTERNAL_TOPICS, since the difference is
functional rather than a label: TOPICS generates the exported PromptTopic union,
the argument getPrompt() accepts, and the keys of the published PROMPTS record,
so an entry there is what makes the topic renderable outside this package.
INTERNAL_TOPICS has no runtime effect — it is a ledger for the completeness
check. Neither affects `taskless help`. The consumer is the reason: engine
selection currently lives in the platform generator as a binary static|runtime
classifier that predates Vale, and exporting the entry is what lets the
generator render the same text instead of drifting from it. The accepted cost is
that an exported name is a promise held for a major version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Task 1.1. `findSgBinary()` hard-coded ast-grep's packaging: the `@ast-grep/cli`
prefix, the `-gnu`/`-msvc` suffixes, the two bin spellings, and an `ast-grep`
identity check. Vale needs the same search with different answers to all four,
so the search moves to `rules/platform-binary.ts` and each engine supplies a
spec.
The parameter that matters is `toolchainSuffix`. ast-grep publishes
`@ast-grep/cli-linux-x64-gnu`; add-vale-binary-packages publishes
`@taskless/vale-linux-x64` with no libc suffix at all. Reusing ast-grep's naming
for Vale would resolve nothing on Linux and surface as the ordinary "Vale is
unavailable" message — a naming bug wearing the costume of a host that never
installed it. test/platform-binary.test.ts pins the naming for both engines
across every published platform, and cross-checks the Vale names against the
optionalDependencies actually declared in package.json so a rename on either
side fails there rather than at runtime.
The resolver returns `{path, tried}` instead of throwing, because the two
callers need different things from a miss: ast-grep is the only executor for
`sg` rules, so `findSgBinary()` keeps throwing, while `findValeBinary()` returns
undefined per D6b — a missing Vale binary makes one engine unavailable and must
not abort the others. `findValeBinary()` caches the miss as well as the hit,
since an absent Vale is the common case and each resolution spawns a subprocess
per candidate.
ast-grep behaviour is unchanged: `isAstGrepBinary` and `findSgBinary` keep their
signatures and their existing tests, all 456 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Tasks 1.2 and 1.3. `rules/vale/run.ts` invokes the resolved binary with
`--config .taskless/vale/.vale.ini --output=JSON --no-exit`, and
`rules/vale/map.ts` turns what comes back into CheckResults.
`--no-exit` is what makes the exit code readable: without it Vale exits non-zero
merely for having found something, which is indistinguishable from failing to
run. With it, non-zero means Vale itself failed.
The result is an outcome union rather than "results or throw". Three of its four
cases — unavailable, timeout, failed — are things the caller reports and keeps
going from, and under D6b the orchestration layer has to tell "Vale found
nothing" from "Vale never ran", a distinction an empty array erases.
Mapping decisions worth naming:
- `rules.` is stripped from the check name. The style directory is called
`rules`, so Vale reports `rules.no-simply` for what a user wrote as
`no-simply`; the prefix is an artifact of config layout, not part of the id.
- An unrecognized severity becomes `warning` rather than being dropped. A
finding whose level we cannot read is still a finding.
- `fix` comes only from a `replace` action's replacement. Vale's other actions
describe an edit whose result is not in the payload, so putting the action
name there would offer to replace the user's match with the word "remove".
The JSON shape was captured from the real binary rather than assumed —
`Action: {Name: "", Params: null}`, `Span`, `Line`, capitalized keys — and the
tests run the real Vale rather than a stub, since what is being asserted is what
Vale does with a config. It ships as an optionalDependency for every platform we
publish, so those cases run in CI too; they skip only on an unsupported arch,
where a stub would be asserting our own mock.
Covers the non-verify half of 1.5: stripping and severity mapping, all three
scoping cases (include, exclude-wins, duplicate matchers merge), missing-binary,
and timeout. The verify half needs 1.4 and lands in unit 2; 1.5 is annotated
rather than ticked so that stays visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…g shape
Two review points from unit 1.
`isValeFailure()` names which outcomes fail the check. The three non-ok cases
are not equivalent and collapsing them is wrong in both directions:
`unavailable` is a skip — an unsupported arch is an ordinary state, not evidence
the user's rules are wrong, and failing there makes `check` unrunnable on a
machine where ast-grep and runtime rules report fine. `timeout` and `failed` are
errors: Vale was present and asked to work, and reporting those as a skip lets a
broken rule file read as "no Vale findings", which is indistinguishable from a
clean run. Exported so orchestration (2.2) derives the exit code from one rule
rather than restating it.
The unknown-severity branch is future-proofing, now labelled as such: Vale
enforces its own vocabulary, rejecting anything outside
[suggestion warning error], so nothing reaches the default today.
Establishing that turned up Vale's config-error payload — a flat
{Line, Path, Text, Code, Span} object rather than findings-keyed-by-file.
Measured against the real binary: it goes to stderr with exit 2 and an empty
stdout, so the existing non-zero-exit branch already reports it and the shape
never reaches the mapper. (An earlier reading of "exit 0" was an artifact of
piping through `head`, which reports the pipe's status, with 2>&1 making stderr
look like stdout.)
The guard is kept as defence rather than a fix, because the failure mode if a
future Vale reports config errors on stdout is not a wrong answer but a crash:
mapping walks Object.entries over Line/Path/Code and calls .map on a number, and
an uncaught throw out of runVale would take the other engines with it (D6b).
Both the type guard and an Array.isArray check in the mapper are covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Vale is a vendored binary upgraded on its cadence, not ours, so the assumptions
our mapping rests on are worth asserting directly. vale-vendor-contract.test.ts
invokes Vale rather than runVale — a test through our wrapper would assert our
interpretation, which is what everything else already covers — and each case
names the code that breaks if the behaviour changes.
Writing it found three things that were wrong, two of them mine.
MATCHER PRECEDENCE. The spec said a disable "takes precedence over an enable,
independent of order". Measured against 3.17.1 that is false in both
directions. Precedence is positional: where two matchers both match a file the
LAST wins, and where one matcher assigns the same key twice — including across
duplicate [glob] sections, which Vale merges — the FIRST wins. So
[marketing/**] YES then [marketing/legacy/**] NO excludes legacy, and the same
two matchers in the opposite order do not. The spec now states that, requires a
disable to be declared after the enable it narrows, and warns that a repeated
assignment inside one glob is discarded — which matters for tooling that
appends to an existing matcher.
The unit-1 test covering this was named "lets a disable win over an enable,
regardless of order" and exercised one order. It passed while the claim it
named was false. Renamed to what it actually asserts, with the opposite order
pinned in the contract file.
EXIT CODES. Vale's exit code keys off severity, not off having found anything:
suggestion and warning exit 0 even without --no-exit, only error exits 1. So
--no-exit is load-bearing exactly for error-level rules, which is where dropping
it would hurt most — every check with a real violation reported as a failed
engine instead of as findings.
EMPTY RESULTS. Vale prints `{}`, not nothing, when it finds nothing. runVale's
empty-stdout branch is therefore insurance rather than the live path, and the
comment claiming otherwise is corrected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`require-changeset` only ever evaluates the bottom PR's own diff, so a changeset written on the tip is invisible to the PR that actually merges — removing `skip-changeset` from this PR turned the check red with the note sitting three branches above it. The stack merges down, so nothing lands until everything does and one note describing the whole change is accurate at the only moment it is read. It is moved here rather than duplicated; the tip's copy goes away as the units restack onto this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
|
@claude /review |
|
Claude finished @thecodedrift's task in 2m 1s —— View job Review checklist
Overall assessmentThis is careful, well-documented work — the doc comments explaining why (toolchainSuffix, identity checks, the Bug (blocking): Real regressions from the Design gap worth a second look: in Smaller correctness concern: Process note: the changeset ( Also flagged inline, lower priority: Not flagged as blocking, but noted during review for awareness: Copilot's earlier review comments about the two |
…required Two gaps in the "One changeset, at the bottom of the stack" guidance, both found on the #71/#93/#94/#95 merging-down stack: - The section said where the changeset lives but never when to create it. Document the trigger: write it on the base branch before cutting the children, so every child inherits a file it can append to. - "Grow it incrementally" is merge-forward reasoning. Under merging down nothing lands until everything does, so a whole-change note is accurate at the only moment it is read. Scope the rule to forward, and state the constraint that holds in both shapes: the file must be in the bottom PR's own diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
| export function toValeCheckResult( | ||
| file: string, | ||
| finding: ValeFinding | ||
| ): CheckResult { | ||
| const [startColumn, endColumn] = finding.Span; | ||
| return { | ||
| source: "vale", | ||
| ruleId: stripRulesPrefix(finding.Check), | ||
| severity: normalizeSeverity(finding.Severity), | ||
| message: finding.Message, | ||
| note: toNote(finding), | ||
| file, | ||
| range: { | ||
| start: { line: finding.Line, column: startColumn }, | ||
| end: { line: finding.Line, column: endColumn }, | ||
| }, |
There was a problem hiding this comment.
Bug: Vale's 1-based Line/Span are written into CheckResult.range unconverted, but range is documented elsewhere as 0-indexed.
packages/cli/src/rules/runtime/harness.ts:22-23 states the contract explicitly: `Finding` line/column are 1-indexed (harness contract); `CheckResult.range` is 0-indexed (ast-grep native — display and `--json` consumers add 1), and findingToCheckResult in that same file does Math.max(0, finding.line - 1) to honor it. ast-grep's own schema (src/generated/ast-grep-rule-schema.json:297-322) documents its native range as 0-based too.
toValeCheckResult here stores finding.Line and finding.Span directly — both of which this file's own doc comments say are 1-based (ValeFinding.Span: "[start, end] columns, 1-based", line 17). The comment on line 142 ("Span is 1-based and inclusive, which matches what ast-grep already emits for column") appears to be incorrect — ast-grep's column is 0-based, not 1-based.
Concretely, formatText (src/util/format.ts:12) does result.range.start.line + 1 unconditionally for every CheckResult regardless of source. A Vale finding reported on line 3 will display as line 4 (and the column will be off by one too).
packages/cli/test/vale-map.test.ts:58-71 currently pins the unconverted (1-based) values as the expected output, so this won't be caught by CI as written — the test encodes the same off-by-one the implementation has.
Suggest subtracting 1 from finding.Line, startColumn, and endColumn (clamped at 0) to match the runtime-rule mapper's pattern, and updating the test fixture accordingly.
| child.on("error", (error) => { | ||
| // Near-unreachable: the binary was verified by running it during | ||
| // resolution, so this means it vanished in between. | ||
| settle({ | ||
| status: "unavailable", | ||
| message: `Vale could not be executed at ${binary}: ${error.message}`, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
child.on("error", ...) here always resolves as status: "unavailable", which isValeFailure() treats as a skip that must not fail check (per its own doc comment: "unavailable is a skip ... not evidence of anything wrong").
But by this point findValeBinary() has already verified the binary by successfully running --version during resolution (isPlatformBinary, platform-binary.ts:104-115). An error event on the actual scan spawn means something changed between resolution and execution — the binary was deleted, permissions changed, or it was quarantined — which is a real, if rare, failure, not "Vale isn't installed here."
Contrast with the analogous case in scan.ts:161-174 (runAstGrepScan's child.on("error")), which treats the same "vanished after verification" scenario as a rejection (a hard failure), not a silent success-path skip. Here the equivalent condition is silently downgraded to "other engines still ran, nothing to report" — a genuine installation problem could go unnoticed by the user.
| label: "ast-grep", | ||
| packagePrefix: "@ast-grep/cli", | ||
| toolchainSuffix: true, | ||
| binaryNames: ["ast-grep", "sg"], |
There was a problem hiding this comment.
binaryNames: ["ast-grep", "sg"] silently reverses the search priority that existed before this refactor.
The removed code (see the diff) explicitly tried alternative (sg) before binary (ast-grep) in both the node_modules/.bin and PATH tiers, with a comment calling this out as deliberate ("Both names, matching the PATH search below"). resolvePlatformBinary now just iterates spec.binaryNames in list order at every tier, so those same two tiers now try ast-grep first — the opposite of the original priority — and the platform-package tier now probes both names (2 require.resolve calls) where it previously only checked one.
Two smaller regressions from the same change, both below in findSgBinary:
- The "install a supported platform build, or put
`sg`on your PATH" hint (line ~104) now hardcodes the literal string"sg". The removed code used`${alternative}`, which was"sg.exe"on Windows — that platform-correct suffix is now lost. resolvePlatformBinary'striedlist (platform-binary.ts:163,167) now lists the platform-package tier twice (once perbinaryNamesentry) instead of once, so the "Looked in: ..." error message has a redundant duplicate entry.
None of this is exercised by a test — sg-binary.test.ts and the new platform-binary.test.ts assert that resolution succeeds/fails, not which of two present candidates wins, so the ordering flip wouldn't be caught by CI.
| child.stdout.on("data", (chunk: Buffer) => { | ||
| stdoutChunks.push(chunk.toString()); | ||
| }); | ||
| child.stderr.on("data", (chunk: Buffer) => { | ||
| stderrChunks.push(chunk.toString()); |
There was a problem hiding this comment.
chunk.toString() decodes each Buffer chunk independently rather than accumulating raw buffers (or using node:string_decoder's StringDecoder) and decoding once. If a multi-byte UTF-8 sequence straddles a chunk boundary — plausible here since Vale lints free-form prose that can contain curly quotes, em dashes, accented characters, etc. — each half gets replaced with U+FFFD independently instead of being reassembled correctly.
Best case this corrupts Message/Match text silently; worst case the corruption lands inside a JSON string's escaping and JSON.parse(stdout) throws, which is then reported as status: "failed", message: "Vale produced output that is not JSON" — a false failure for a Vale run that actually completed. runAstGrepScan (scan.ts:145) avoids this by reading child.stdout through node:readline's createInterface, which decodes safely.
| `check` now dispatches by engine directory and runs ast-grep, Vale, and runtime | ||
| rules concurrently, merging their findings into one result set. Vale rules live | ||
| in `.taskless/vale/` and execute against the committed `.vale.ini`; an | ||
| unavailable Vale reports itself and the other engines still return, while a Vale | ||
| that times out or rejects its config fails the check rather than passing as a | ||
| clean run. Vale rules are verified from `rule-tests/<rule>/pass|fail` fixtures | ||
| against a generated per-rule config. | ||
|
|
||
| Adds the `engine-selection` knowledge topic — which engine enforces a given | ||
| rule, and why — available from `taskless help engine-selection` and exported | ||
| through `@taskless/cli/prompts`. |
There was a problem hiding this comment.
This changeset text describes work from units 2-4 of the stack that isn't in this PR's diff: concurrent multi-engine dispatch/merge in check (lines 7-8 — commands/check.ts isn't touched here, and tasks 2.1-2.3 are unchecked), fixture-based Vale rule verification (lines 12-13 — no rules/vale/verify.ts in this diff, task 1.4 unchecked), and the engine-selection help topic (lines 15-17 — no src/help/* changes here, tasks 3.1-3.7 unchecked).
Per the root CLAUDE.md's stacked-PR guidance ("One changeset, at the bottom of the stack, grown as the stack grows"): "Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them." As written, a reviewer of this PR alone sees a release note for three units of work that haven't landed yet. Worth trimming this changeset down to unit 1's scope (binary resolution, Vale runner, CheckResult mapping) and growing it in the follow-up PRs as the proposal itself describes.
| export function isValeFailure(outcome: ValeRunOutcome): boolean { | ||
| return outcome.status === "timeout" || outcome.status === "failed"; | ||
| } |
There was a problem hiding this comment.
Minor API-design note: isValeFailure is a free function callers must remember to invoke rather than severity being encoded on ValeRunOutcome itself. Currently there are zero production callers (grep confirms only tests reference it — orchestration lands in task 2.2), so nothing exercises this yet, but it'd be worth considering a blocking: boolean field (or similar) on the type so a future author writing the natural-looking outcome.status !== "ok" check is visibly wrong at compile time rather than only wrong once wired up — that specific mistake would fail check on every host missing the Vale binary, which is exactly the regression D6b exists to prevent.
| * When a rule file is malformed, Vale emits one flat object like | ||
| * `{Line, Path, Text: "'level' must be one of [...]", Code: "E201", Span}` | ||
| * rather than findings-keyed-by-file. | ||
| * | ||
| * Measured against the real binary (a rule with an out-of-vocabulary `level`): | ||
| * it goes to **stderr** with **exit 2** and an empty stdout, so `runVale`'s | ||
| * non-zero-exit branch already reports it as a failure and this shape never | ||
| * reaches the mapper today. |
There was a problem hiding this comment.
Minor: this comment says the config-error payload is measured to go to stderr with exit 2 (so it "never reaches the mapper today"), but run.ts:173-174's comment says the opposite — "a malformed rule makes Vale emit a config error and still exit 0, so this is the only place the difference is detectable." These two comments describe contradictory measured behavior for the same scenario; worth reconciling so a future reader isn't left guessing which one is accurate.
Stack (root → tip):
Unit 1 of
add-vale-rule-engine— the bottom of a four-unit stack that merges down. This PR carries the change proposal plus unit 1's implementation.Stack
openspec/add-vale-rule-engine← this PRCheckResultmapping…-2-verify…-3-orchestration…-4-topicTOPICSentry, archiveMerging down, per the proposal: the Vale engine is only correct once
checkcan dispatch to it — the runner without orchestration ships an engine that never executes, and orchestration without the runner dispatches to nothing. Units merge tip→root, then one protected merge of this branch tomain.Landed so far — task 1.1
findSgBinary()hard-coded ast-grep's packaging: the@ast-grep/cliprefix, the-gnu/-msvcsuffixes, two bin spellings, and anast-grepidentity check. Vale needs the same search with different answers to all four, so the search moved torules/platform-binary.tsand each engine supplies a spec.The parameter that matters is
toolchainSuffix. ast-grep publishes@ast-grep/cli-linux-x64-gnu;add-vale-binary-packagespublishes@taskless/vale-linux-x64with no libc suffix. Reusing ast-grep's naming for Vale would resolve nothing on Linux and surface as the ordinary "Vale is unavailable" message — a naming bug wearing the costume of a host that never installed it.test/platform-binary.test.tspins the naming for both engines across every published platform and cross-checks the Vale names against theoptionalDependenciesactually declared, so a rename on either side fails there rather than at runtime.The resolver returns
{path, tried}rather than throwing, because the callers need different things from a miss. ast-grep is the only executor forsgrules, sofindSgBinary()still throws;findValeBinary()returns undefined per D6b — a missing Vale binary makes one engine unavailable and must not abort the others. It caches the miss as well as the hit, since an absent Vale is the common case and each resolution spawns a subprocess per candidate.ast-grep behaviour is unchanged:
isAstGrepBinaryandfindSgBinarykeep their signatures and their existing tests.Still to come in this unit
rules/vale/run.ts— invoke Vale against the committed config with JSON output, bounded by a subprocess timeoutCheckResult:rules.prefix stripping, severity normalization (suggestion → hint), range fromLine/SpanVerification
pnpm --filter @taskless/cli test→ 456 passed; typecheck, lint, prettier clean.Refs OSS-21