Skip to content

Commit aa4ee7b

Browse files
committed
feat(cli): publish the engine resolver as @taskless/cli/node/runtimes
A rule's behaviour is a property of the engine version that executes it, not of the version it was written against. A generator sandbox pinned @ast-grep/cli 0.41.1 by hand while this package pins 0.45.2, so a capture that narrows differently across those four minors verifies clean there and matches nothing here: a rule that passes its gate and never fires, with both sides reporting success. The new entry exports resolvePlatformBinary along with AST_GREP_BINARY and VALE_BINARY, so a consumer installs the pinned CLI, lets its optional dependencies bring the platform binaries, and asks this resolver where they are. The engine version becomes a consequence of the CLI pin rather than a second fact tracked alongside it. AST_GREP_BINARY moves to rules/ast-grep-binary.ts so a consumer wanting a path does not load a scanner to get one; Vale's spec was already a leaf. The build's entry classification is now exhaustive. LIBRARY_ENTRIES is opt-in, so an export joining no list was checked by nothing and forgetting was indistinguishable from a deliberate exemption. Every published export must now name itself graph-asserted or deliberately host-bound, and the build fails on one that does neither. A host-bound entry is excused from the host-capability rule only; it still may not reach the command layer. That last rule also had to be strengthened. It compared against the bin chunk's file name, and importing src/index.ts from a library entry makes rollup hoist the CLI into a shared chunk and leave dist/index.js a 50-byte re-export facade the library entry never imports — the check reported nothing while the entry carried the whole command layer. It now compares module ids, which chunking cannot move. resolvePlatformBinary's contract is unchanged: a miss is { path: undefined, tried } rather than a throw, because check must lose one engine instead of the whole run. The entry documents that so a verifier can fail closed on it, which a sandbox must, since an optional dependency that did not install resolves nothing for the same reason a laptop does.
1 parent 564296e commit aa4ee7b

13 files changed

Lines changed: 567 additions & 56 deletions

File tree

‎.changeset/node-runtimes-export.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@taskless/cli": patch
3+
---
4+
5+
`@taskless/cli/node/runtimes` publishes the engine binary resolver.
6+
7+
It exports `resolvePlatformBinary` along with the `AST_GREP_BINARY` and
8+
`VALE_BINARY` specs, so a service that verifies a generated rule can locate the
9+
same ast-grep and the same Vale that `taskless check` executes, instead of
10+
pinning the engine version by hand in a second place.
11+
12+
The second pin is the problem it removes. A generator sandbox pinned
13+
`@ast-grep/cli` at 0.41.1 while this package pinned 0.45.2, and a capture that
14+
narrows differently across those four minors verifies clean in the sandbox and
15+
matches nothing on the machine that runs it — a rule that passes its gate and
16+
never fires, with both sides reporting success. Installing this package and
17+
asking it where the binary is makes the engine version a consequence of the CLI
18+
pin rather than a fact tracked alongside it.
19+
20+
`node/` is in the specifier because resolution spawns each candidate to make it
21+
identify itself, reads the filesystem, and consults `PATH`. `/prompts` and
22+
`/layout` remain host-free and a Worker still imports them; the build now
23+
requires every published export to declare which of the two it is, so this
24+
entry's exemption is written down rather than inferred from a missing list
25+
entry.
26+
27+
Resolution still reports a miss as `{ path: undefined, tried }` rather than
28+
throwing. That is what lets `check` lose one engine instead of the whole run,
29+
and it is why a verifier should fail closed on `undefined` itself: a sandbox
30+
whose optional dependency did not install resolves nothing for the same reason a
31+
laptop does, and skipping verification there reports a clean generation for a
32+
rule nothing checked.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-09-02
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
## Why
2+
3+
A rule's behaviour is a property of the engine version that executes it, not of
4+
the version it was written against. The Taskless Cloud generator verifies a
5+
generated runtime rule in a sandbox that pins `@ast-grep/cli` **0.41.1** by hand
6+
in a Dockerfile, while `taskless check` on a developer's machine runs this
7+
package's pinned **0.45.2**. Four minors apart. A capture that narrows
8+
differently across them verifies clean on their side and matches nothing on
9+
ours: a rule that passes its gate and never fires, with both sides reporting
10+
success. That is live right now.
11+
12+
The Dockerfile's own comment says the pins exist so the check validates "with
13+
the same ast-grep the rule was generated against". Generation never runs
14+
ast-grep. The version that matters is the one that **executes** the rule, and
15+
that is this CLI's.
16+
17+
There is no way to close that gap by pinning harder. Two teams tracking the same
18+
engine version in two places drift, and the drift is silent in exactly the
19+
direction that hurts. What removes it is making the engine version a consequence
20+
of the CLI version rather than a parallel fact: the consumer installs the pinned
21+
CLI, its optional dependencies bring the platform binaries, and it asks our
22+
resolver where they are. The versions then move together because they are the
23+
same pin.
24+
25+
## What Changes
26+
27+
- A new published entry, `@taskless/cli/node/runtimes`, exporting the CLI's own
28+
`resolvePlatformBinary`, the `AST_GREP_BINARY` and `VALE_BINARY` specs, and
29+
the types a consumer needs to hold the result.
30+
- `AST_GREP_BINARY` moves from `rules/scan.ts` to `rules/ast-grep-binary.ts`, so
31+
a consumer that wants a path does not load a scanner to get one. Vale's spec
32+
already sits in a leaf module.
33+
- The build's entry classification becomes exhaustive. `LIBRARY_ENTRIES` is
34+
opt-in, so before this change an export that joined no list was checked by
35+
nothing, and forgetting was indistinguishable from a deliberate exemption.
36+
Every published export must now name itself as either graph-asserted or
37+
deliberately host-bound, and the build fails on one that does neither.
38+
- The CLI-entry half of the graph guard now asks about the bin entry's **module
39+
id** rather than the bin **chunk's** file name, and applies to host-bound
40+
entries too.
41+
42+
**No change to `resolvePlatformBinary`'s contract.** It returns
43+
`{ path: undefined, tried }` rather than throwing, because `check` must lose one
44+
engine rather than the whole run. A verifier wants the opposite and will fail
45+
closed on `undefined` itself; the entry documents that the return is a value
46+
precisely so each side can decide.
47+
48+
## Capabilities
49+
50+
### New Capabilities
51+
52+
- `cli-runtime-binary-export`: the published entry that resolves the engine
53+
binaries this CLI executes, and the build-time classification that keeps every
54+
published entry's host access declared rather than assumed.
55+
56+
## Impact
57+
58+
- New public surface on `@taskless/cli`. Additive; nothing existing changes
59+
shape. `patch` under pre-1.0 guidance, where `minor` is reserved for breaking
60+
changes.
61+
- `@taskless/cli/prompts` and `@taskless/cli/layout` are unaffected. Their graphs
62+
stay host-free and the build still refuses to emit them otherwise — the new
63+
entry is excused from that rule by name, not by absence.
64+
- Consumers gain a reason to depend on the CLI package from a service, so the
65+
entry's Node requirement is stated in its import path (`node/`) where a reader
66+
meets it before a build error does.
67+
68+
## Delivery shape
69+
70+
**Single PR.** One entry module, one spec relocation, one build guard, and the
71+
spec delta that records why the exemption is legitimate. The guard is the half
72+
that makes the exemption reviewable, so splitting it out would ship a new
73+
unchecked published surface for as long as the two halves were apart — which is
74+
the exact failure the guard exists to prevent.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
## ADDED Requirements
2+
3+
### Requirement: The engine binary resolver is published
4+
5+
The CLI SHALL publish, as an importable entry, the resolver it uses to locate the
6+
engine binaries it executes, together with the per-engine package specs that
7+
resolver takes.
8+
9+
A consumer that verifies a generated rule has to execute it, and a rule's
10+
behaviour is a property of the engine version that runs it. Tracking that version
11+
separately from the CLI version means two pins in two repositories, which drift
12+
silently: a `0.41.1` sandbox verified a capture clean that a `0.45.2` developer
13+
machine then matched nothing with. Installing the CLI and asking it where the
14+
binary is makes the engine version a consequence of the CLI pin rather than a
15+
parallel fact.
16+
17+
#### Scenario: A consumer resolves the same binary the CLI executes
18+
19+
- **WHEN** a consumer imports the published runtimes entry and resolves an engine
20+
spec
21+
- **THEN** it SHALL receive the absolute path of a binary that identified itself
22+
as that engine
23+
- **AND** the resolver and the specs SHALL be the same modules the CLI itself
24+
dispatches on, not a copy kept in step
25+
26+
#### Scenario: The pinned platform build is preferred
27+
28+
- **WHEN** the engine's pinned per-platform package is installed
29+
- **THEN** resolution SHALL try it before a linked binary or one on `PATH`
30+
31+
### Requirement: A binary that cannot be resolved is reported as a value
32+
33+
Resolution SHALL report a miss by returning an absent path alongside the list of
34+
locations searched, and SHALL NOT throw.
35+
36+
The two sides want opposite things from a miss, and neither can be imposed on the
37+
other. `taskless check` runs several engines: a host without Vale installed must
38+
lose the Vale rules rather than the whole run. A verifier must fail closed,
39+
because a sandbox whose optional dependency did not install resolves nothing for
40+
the same reason a laptop does, and skipping verification there reports a clean
41+
generation for a rule nothing checked. Encoding the miss as a value is what lets
42+
each caller choose; the entry SHALL document that choice rather than leaving it
43+
to be inferred.
44+
45+
#### Scenario: Nothing resolves
46+
47+
- **WHEN** no candidate location holds a binary that identifies itself as the
48+
engine
49+
- **THEN** resolution SHALL return an absent path
50+
- **AND** it SHALL return the locations it searched, in order
51+
- **AND** it SHALL NOT throw
52+
53+
#### Scenario: A file exists but is not the binary
54+
55+
- **WHEN** a candidate path exists but does not identify itself as the engine
56+
- **THEN** resolution SHALL reject that candidate and continue searching
57+
58+
### Requirement: A host-bound entry declares its host requirement in its path
59+
60+
An entry that requires Node SHALL carry that requirement in its published
61+
specifier rather than only in documentation.
62+
63+
The published surface is what a consumer reads first. A specifier that names the
64+
host states the constraint before a bundler error has to, and it distinguishes
65+
this entry from the host-free ones beside it, which a Worker imports today.
66+
67+
#### Scenario: The specifier names the host
68+
69+
- **WHEN** a consumer reads the import path of an entry that spawns processes,
70+
reads the filesystem, or consults `PATH`
71+
- **THEN** the path SHALL identify the host it requires
72+
73+
### Requirement: Every published export declares whether it is host-bound
74+
75+
The build SHALL classify every entry named in the package's `exports` as either
76+
graph-asserted host-free or deliberately host-bound, and SHALL fail when a
77+
published export belongs to neither.
78+
79+
The graph assertion is opt-in, so before this requirement an entry that joined no
80+
list was checked by nothing, and forgetting to enroll a new surface was
81+
indistinguishable from exempting it on purpose. Requiring a classification turns
82+
the exemption into a claim a reviewer can disagree with. A host-bound entry is
83+
excused only from the host-capability rule; it SHALL still be forbidden from
84+
reaching the CLI's command layer, because needing a subprocess is not a reason to
85+
also ship the command tree.
86+
87+
#### Scenario: An unclassified published export fails the build
88+
89+
- **WHEN** the package publishes an export whose entry appears in neither
90+
classification
91+
- **THEN** the build SHALL fail
92+
- **AND** the artifact SHALL NOT be emitted
93+
94+
#### Scenario: A published export names no built entry
95+
96+
- **WHEN** a published export points at a file the build does not emit
97+
- **THEN** the build SHALL fail
98+
99+
#### Scenario: A host-bound entry reaching the command layer fails the build
100+
101+
- **WHEN** a host-bound entry's graph reaches the CLI entry module
102+
- **THEN** the build SHALL fail
103+
- **AND** the check SHALL be made against the entry module rather than against
104+
the emitted CLI chunk, since bundling may move the command layer into a shared
105+
chunk the CLI entry itself merely re-exports
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Tasks
2+
3+
Delivery shape: **single PR**. The entry and the guard that makes its exemption
4+
reviewable land together; apart, the first ships an unchecked published surface.
5+
6+
## 1. Give the ast-grep spec a leaf module
7+
8+
- [x] 1.1 Move `AST_GREP_BINARY` from `src/rules/scan.ts` to
9+
`src/rules/ast-grep-binary.ts`, and say in the comment why it lives alone:
10+
`scan.ts` reaches `rules/engines.ts` and through it `node:fs/promises` and
11+
the CLI's error type, none of which a consumer asking for a path needs.
12+
- [x] 1.2 Point `scan.ts` and the two tests that name it at the new module.
13+
`VALE_BINARY` needs no move; `rules/vale/binary.ts` already imports nothing
14+
but the resolver.
15+
16+
## 2. The entry
17+
18+
- [x] 2.1 Add `src/node/runtimes/index.ts` re-exporting `resolvePlatformBinary`,
19+
`platformPackageName`, `pathCommandName`, `findOnPath`, `isPlatformBinary`,
20+
both specs, and `PlatformBinarySpec` / `PlatformBinaryResolution`.
21+
`.js` extensions on the specifiers, as on the other published entries, or
22+
`tsc` copies extensionless paths into the emitted `.d.ts` and a consumer on
23+
`node16` resolution cannot follow them.
24+
- [x] 2.2 Document that a miss is a value rather than an exception, and that a
25+
verifier should fail closed on it while `check` deliberately does not.
26+
- [x] 2.3 Add the `./node/runtimes` export and the vite entry.
27+
- [x] 2.4 Add the entry to `tsconfig.public.json` so its types ship.
28+
29+
## 3. Make the classification exhaustive
30+
31+
- [x] 3.1 Derive the vite entry map from one `ENTRY_SOURCES` record, so the
32+
classification check and the build read the same list.
33+
- [x] 3.2 Add `HOST_BOUND_ENTRIES`, and `assertExportsClassified()`, which fails
34+
the build when a published export names no built entry or appears in
35+
neither classification.
36+
- [x] 3.3 Apply the CLI-entry rule to host-bound entries too, and state at both
37+
lists that absence is no longer an exemption.
38+
- [x] 3.4 Verify it bites, three ways: an export naming an unbuilt entry; the new
39+
entry removed from `HOST_BOUND_ENTRIES`, which is the exempt-by-omission
40+
case; and the entry importing `src/index.ts`.
41+
42+
## 4. The chunk-versus-module correction
43+
44+
- [x] 4.1 The CLI-entry rule compared against the bin chunk's file name, and task
45+
3.4's third test found that evadable: importing `src/index.ts` from a
46+
library entry made rollup hoist the CLI into a shared chunk and leave
47+
`dist/index.js` a 50-byte re-export facade, which the library entry did not
48+
import. The check reported nothing while the entry carried the whole
49+
command layer. Compare against the bin entry's module id instead, which
50+
chunking cannot move.
51+
52+
## 5. Checks
53+
54+
- [x] 5.1 `pnpm build`, `pnpm typecheck`, `pnpm lint`,
55+
`pnpm --filter @taskless/cli exec vitest run`, `pnpm cli check`.
56+
- [x] 5.2 Prove the built artifact, not the source: import
57+
`@taskless/cli/node/runtimes` by its published specifier from outside the
58+
package and resolve both engines, showing each path and that identity
59+
verified.
60+
- [x] 5.3 Changeset, `patch`.
61+
62+
## 6. Close out
63+
64+
- [ ] 6.1 Archive the change. Archiving is what promotes the delta into the
65+
standing spec.

‎packages/cli/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"./layout": {
3535
"types": "./dist/layout/index.d.ts",
3636
"import": "./dist/layout.js"
37+
},
38+
"./node/runtimes": {
39+
"types": "./dist/node/runtimes/index.d.ts",
40+
"import": "./dist/node/runtimes.js"
3741
}
3842
},
3943
"files": [
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Public entry for `@taskless/cli/node/runtimes`.
3+
*
4+
* Where the engine binaries this CLI executes actually live on this host, and
5+
* how to find them. It is the CLI's own resolver and the CLI's own package
6+
* specs — not a copy kept in step, the actual modules — so a consumer that
7+
* installs this package resolves the same ast-grep and the same Vale that
8+
* `taskless check` would run.
9+
*
10+
* ## Why this is published
11+
*
12+
* A service that verifies a generated rule has to execute it, and a rule's
13+
* behaviour is a property of the engine version that runs it, not of the
14+
* version it was written against. ast-grep in particular changes what a capture
15+
* matches across minors: a rule verified under one and executed under another
16+
* can verify clean and then match nothing, which is a rule that passes its gate
17+
* and never fires. Nothing reports that, because both sides succeeded.
18+
*
19+
* The alternative to importing this is pinning the engine version by hand
20+
* alongside the CLI version, in a second place, and keeping the two in step.
21+
* They drift, and the drift is silent in exactly the direction that matters.
22+
* Installing this package and asking it where the binary is makes the engine
23+
* version a consequence of the CLI version rather than a parallel fact: the
24+
* pinned platform packages arrive as this package's own optional dependencies,
25+
* and they move when the pin moves.
26+
*
27+
* ## This entry requires Node, and says so in its path
28+
*
29+
* `node/` is in the specifier deliberately. Resolution spawns each candidate to
30+
* make it identify itself, reads the filesystem, and consults `PATH`, so this
31+
* graph reaches `node:child_process`, `node:fs`, `node:module`, `node:path` and
32+
* `node:url`. It will not run on a Worker or in a browser, and the import path
33+
* says that before a build error has to.
34+
*
35+
* This is the difference between this entry and `@taskless/cli/prompts` and
36+
* `@taskless/cli/layout`, which are host-free and whose builds fail if their
37+
* graphs ever reach a host capability. Every published entry is classified as
38+
* one or the other in `vite.config.ts`, and the build fails on an export
39+
* belonging to neither, so this exemption is a declaration rather than an
40+
* omission.
41+
*
42+
* ## Resolution reports a miss as a value, and each side decides what it means
43+
*
44+
* {@link resolvePlatformBinary} returns `{ path: undefined, tried }` when no
45+
* candidate verified. It does not throw, and that is deliberate rather than
46+
* incidental: `taskless check` runs several engines, and a host without Vale
47+
* installed must lose the Vale rules rather than the whole run. Encoding "not
48+
* found" as a value is what lets a caller choose.
49+
*
50+
* A verifier should choose the opposite. A sandbox whose optional dependency
51+
* did not install resolves `undefined` for the same reason a developer's laptop
52+
* does — nothing is there — but the consequence is not a skipped engine, it is
53+
* a verification that ran no engine and reported success. Fail closed on
54+
* `undefined`, and use `tried` to say where you looked:
55+
*
56+
* ```ts
57+
* import {
58+
* AST_GREP_BINARY,
59+
* resolvePlatformBinary,
60+
* } from "@taskless/cli/node/runtimes";
61+
*
62+
* const { path, tried } = resolvePlatformBinary(AST_GREP_BINARY);
63+
* if (path === undefined) {
64+
* throw new Error(`ast-grep not resolved. Looked in: ${tried.join(", ")}`);
65+
* }
66+
* ```
67+
*
68+
* ## What a resolved path is, and is not
69+
*
70+
* It is a path that exists and that answered `--version` with output matching
71+
* the spec's `identity`, so it is the binary and not a placeholder a failed
72+
* install left behind. It is not a promise about which version answered: the
73+
* first candidate tried is the pinned platform package, but a host that only
74+
* has the engine on `PATH` resolves that instead. Read `--version` yourself if
75+
* the version is what you are relying on.
76+
*/
77+
78+
// The `.js` extensions are deliberate, for the same reason they are on the
79+
// prompts and layout entries: this module is a published entry, so `tsc` copies
80+
// these specifiers verbatim into `dist/node/runtimes/index.d.ts`, where an
81+
// extensionless specifier fails to resolve for a consumer on
82+
// `moduleResolution: node16`/`nodenext`.
83+
export {
84+
findOnPath,
85+
isPlatformBinary,
86+
pathCommandName,
87+
platformPackageName,
88+
resolvePlatformBinary,
89+
type PlatformBinaryResolution,
90+
type PlatformBinarySpec,
91+
} from "../../rules/platform-binary.js";
92+
export { AST_GREP_BINARY } from "../../rules/ast-grep-binary.js";
93+
export { VALE_BINARY } from "../../rules/vale/binary.js";

0 commit comments

Comments
 (0)