Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/node-runtimes-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@taskless/cli": patch
---

`@taskless/cli/node/runtimes` publishes the engine binary resolver.

It exports `resolvePlatformBinary` along with the `AST_GREP_BINARY` and
`VALE_BINARY` specs, so a service that verifies a generated rule can locate the
same ast-grep and the same Vale that `taskless check` executes, instead of
pinning the engine version by hand in a second place.

The second pin is the problem it removes. A generator sandbox pinned
`@ast-grep/cli` at 0.41.1 while this package pinned 0.45.2, and a capture that
narrows differently across those four minors verifies clean in the sandbox and
matches nothing on the machine that runs it — a rule that passes its gate and
never fires, with both sides reporting success. Installing this package and
asking it where the binary is makes the engine version a consequence of the CLI
pin rather than a fact tracked alongside it.

`node/` is in the specifier because resolution spawns each candidate to make it
identify itself, reads the filesystem, and consults `PATH`. `/prompts` and
`/layout` remain host-free and a Worker still imports them; the build now
requires every published export to declare which of the two it is, so this
entry's exemption is written down rather than inferred from a missing list
entry.

Resolution still reports a miss as `{ path: undefined, tried }` rather than
throwing. That is what lets `check` lose one engine instead of the whole run,
and it is why a verifier should fail closed on `undefined` itself: a sandbox
whose optional dependency did not install resolves nothing for the same reason a
laptop does, and skipping verification there reports a clean generation for a
rule nothing checked.
2 changes: 2 additions & 0 deletions openspec/changes/node-runtimes-export/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-02
74 changes: 74 additions & 0 deletions openspec/changes/node-runtimes-export/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Why

A rule's behaviour is a property of the engine version that executes it, not of
the version it was written against. The Taskless Cloud generator verifies a
generated runtime rule in a sandbox that pins `@ast-grep/cli` **0.41.1** by hand
in a Dockerfile, while `taskless check` on a developer's machine runs this
package's pinned **0.45.2**. Four minors apart. A capture that narrows
differently across them verifies clean on their side and matches nothing on
ours: a rule that passes its gate and never fires, with both sides reporting
success. That is live right now.

The Dockerfile's own comment says the pins exist so the check validates "with
the same ast-grep the rule was generated against". Generation never runs
ast-grep. The version that matters is the one that **executes** the rule, and
that is this CLI's.

There is no way to close that gap by pinning harder. Two teams tracking the same
engine version in two places drift, and the drift is silent in exactly the
direction that hurts. What removes it is making the engine version a consequence
of the CLI version rather than a parallel fact: the consumer installs the pinned
CLI, its optional dependencies bring the platform binaries, and it asks our
resolver where they are. The versions then move together because they are the
same pin.

## What Changes

- A new published entry, `@taskless/cli/node/runtimes`, exporting the CLI's own
`resolvePlatformBinary`, the `AST_GREP_BINARY` and `VALE_BINARY` specs, and
the types a consumer needs to hold the result.
- `AST_GREP_BINARY` moves from `rules/scan.ts` to `rules/ast-grep-binary.ts`, so
a consumer that wants a path does not load a scanner to get one. Vale's spec
already sits in a leaf module.
- The build's entry classification becomes exhaustive. `LIBRARY_ENTRIES` is
opt-in, so before this change an export that joined no list was checked by
nothing, and forgetting was indistinguishable from a deliberate exemption.
Every published export must now name itself as either graph-asserted or
deliberately host-bound, and the build fails on one that does neither.
- The CLI-entry half of the graph guard now asks about the bin entry's **module
id** rather than the bin **chunk's** file name, and applies to host-bound
entries too.

**No change to `resolvePlatformBinary`'s contract.** It returns
`{ path: undefined, tried }` rather than throwing, because `check` must lose one
engine rather than the whole run. A verifier wants the opposite and will fail
closed on `undefined` itself; the entry documents that the return is a value
precisely so each side can decide.

## Capabilities

### New Capabilities

- `cli-runtime-binary-export`: the published entry that resolves the engine
binaries this CLI executes, and the build-time classification that keeps every
published entry's host access declared rather than assumed.

## Impact

- New public surface on `@taskless/cli`. Additive; nothing existing changes
shape. `patch` under pre-1.0 guidance, where `minor` is reserved for breaking
changes.
- `@taskless/cli/prompts` and `@taskless/cli/layout` are unaffected. Their graphs
stay host-free and the build still refuses to emit them otherwise — the new
entry is excused from that rule by name, not by absence.
- Consumers gain a reason to depend on the CLI package from a service, so the
entry's Node requirement is stated in its import path (`node/`) where a reader
meets it before a build error does.

## Delivery shape

**Single PR.** One entry module, one spec relocation, one build guard, and the
spec delta that records why the exemption is legitimate. The guard is the half
that makes the exemption reviewable, so splitting it out would ship a new
unchecked published surface for as long as the two halves were apart — which is
the exact failure the guard exists to prevent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## ADDED Requirements

### Requirement: The engine binary resolver is published

The CLI SHALL publish, as an importable entry, the resolver it uses to locate the
engine binaries it executes, together with the per-engine package specs that
resolver takes.

A consumer that verifies a generated rule has to execute it, and a rule's
behaviour is a property of the engine version that runs it. Tracking that version
separately from the CLI version means two pins in two repositories, which drift
silently: a `0.41.1` sandbox verified a capture clean that a `0.45.2` developer
machine then matched nothing with. Installing the CLI and asking it where the
binary is makes the engine version a consequence of the CLI pin rather than a
parallel fact.

#### Scenario: A consumer resolves the same binary the CLI executes

- **WHEN** a consumer imports the published runtimes entry and resolves an engine
spec
- **THEN** it SHALL receive the absolute path of a binary that identified itself
as that engine
- **AND** the resolver and the specs SHALL be the same modules the CLI itself
dispatches on, not a copy kept in step

#### Scenario: The pinned platform build is preferred

- **WHEN** the engine's pinned per-platform package is installed
- **THEN** resolution SHALL try it before a linked binary or one on `PATH`

### Requirement: A binary that cannot be resolved is reported as a value

Resolution SHALL report a miss by returning an absent path alongside the list of
locations searched, and SHALL NOT throw.

The two sides want opposite things from a miss, and neither can be imposed on the
other. `taskless check` runs several engines: a host without Vale installed must
lose the Vale rules rather than the whole run. A verifier must fail closed,
because a sandbox whose optional dependency did not install resolves nothing for
the same reason a laptop does, and skipping verification there reports a clean
generation for a rule nothing checked. Encoding the miss as a value is what lets
each caller choose; the entry SHALL document that choice rather than leaving it
to be inferred.

#### Scenario: Nothing resolves

- **WHEN** no candidate location holds a binary that identifies itself as the
engine
- **THEN** resolution SHALL return an absent path
- **AND** it SHALL return the locations it searched, in order
- **AND** it SHALL NOT throw

#### Scenario: A file exists but is not the binary

- **WHEN** a candidate path exists but does not identify itself as the engine
- **THEN** resolution SHALL reject that candidate and continue searching

### Requirement: A host-bound entry declares its host requirement in its path

An entry that requires Node SHALL carry that requirement in its published
specifier rather than only in documentation.

The published surface is what a consumer reads first. A specifier that names the
host states the constraint before a bundler error has to, and it distinguishes
this entry from the host-free ones beside it, which a Worker imports today.

#### Scenario: The specifier names the host

- **WHEN** a consumer reads the import path of an entry that spawns processes,
reads the filesystem, or consults `PATH`
- **THEN** the path SHALL identify the host it requires

### Requirement: Every published export declares whether it is host-bound

The build SHALL classify every entry named in the package's `exports` as either
graph-asserted host-free or deliberately host-bound, and SHALL fail when a
published export belongs to neither.

The graph assertion is opt-in, so before this requirement an entry that joined no
list was checked by nothing, and forgetting to enroll a new surface was
indistinguishable from exempting it on purpose. Requiring a classification turns
the exemption into a claim a reviewer can disagree with. A host-bound entry is
excused only from the host-capability rule; it SHALL still be forbidden from
reaching the CLI's command layer, because needing a subprocess is not a reason to
also ship the command tree.

#### Scenario: An unclassified published export fails the build

- **WHEN** the package publishes an export whose entry appears in neither
classification
- **THEN** the build SHALL fail
- **AND** the artifact SHALL NOT be emitted

#### Scenario: A published export names no built entry

- **WHEN** a published export points at a file the build does not emit
- **THEN** the build SHALL fail

#### Scenario: A host-bound entry reaching the command layer fails the build

- **WHEN** a host-bound entry's graph reaches the CLI entry module
- **THEN** the build SHALL fail
- **AND** the check SHALL be made against the entry module rather than against
the emitted CLI chunk, since bundling may move the command layer into a shared
chunk the CLI entry itself merely re-exports
65 changes: 65 additions & 0 deletions openspec/changes/node-runtimes-export/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Tasks

Delivery shape: **single PR**. The entry and the guard that makes its exemption
reviewable land together; apart, the first ships an unchecked published surface.

## 1. Give the ast-grep spec a leaf module

- [x] 1.1 Move `AST_GREP_BINARY` from `src/rules/scan.ts` to
`src/rules/ast-grep-binary.ts`, and say in the comment why it lives alone:
`scan.ts` reaches `rules/engines.ts` and through it `node:fs/promises` and
the CLI's error type, none of which a consumer asking for a path needs.
- [x] 1.2 Point `scan.ts` and the two tests that name it at the new module.
`VALE_BINARY` needs no move; `rules/vale/binary.ts` already imports nothing
but the resolver.

## 2. The entry

- [x] 2.1 Add `src/node/runtimes/index.ts` re-exporting `resolvePlatformBinary`,
`platformPackageName`, `pathCommandName`, `findOnPath`, `isPlatformBinary`,
both specs, and `PlatformBinarySpec` / `PlatformBinaryResolution`.
`.js` extensions on the specifiers, as on the other published entries, or
`tsc` copies extensionless paths into the emitted `.d.ts` and a consumer on
`node16` resolution cannot follow them.
- [x] 2.2 Document that a miss is a value rather than an exception, and that a
verifier should fail closed on it while `check` deliberately does not.
- [x] 2.3 Add the `./node/runtimes` export and the vite entry.
- [x] 2.4 Add the entry to `tsconfig.public.json` so its types ship.

## 3. Make the classification exhaustive

- [x] 3.1 Derive the vite entry map from one `ENTRY_SOURCES` record, so the
classification check and the build read the same list.
- [x] 3.2 Add `HOST_BOUND_ENTRIES`, and `assertExportsClassified()`, which fails
the build when a published export names no built entry or appears in
neither classification.
- [x] 3.3 Apply the CLI-entry rule to host-bound entries too, and state at both
lists that absence is no longer an exemption.
- [x] 3.4 Verify it bites, three ways: an export naming an unbuilt entry; the new
entry removed from `HOST_BOUND_ENTRIES`, which is the exempt-by-omission
case; and the entry importing `src/index.ts`.

## 4. The chunk-versus-module correction

- [x] 4.1 The CLI-entry rule compared against the bin chunk's file name, and task
3.4's third test found that evadable: importing `src/index.ts` from a
library entry made rollup hoist the CLI into a shared chunk and leave
`dist/index.js` a 50-byte re-export facade, which the library entry did not
import. The check reported nothing while the entry carried the whole
command layer. Compare against the bin entry's module id instead, which
chunking cannot move.

## 5. Checks

- [x] 5.1 `pnpm build`, `pnpm typecheck`, `pnpm lint`,
`pnpm --filter @taskless/cli exec vitest run`, `pnpm cli check`.
- [x] 5.2 Prove the built artifact, not the source: import
`@taskless/cli/node/runtimes` by its published specifier from outside the
package and resolve both engines, showing each path and that identity
verified.
- [x] 5.3 Changeset, `patch`.

## 6. Close out

- [ ] 6.1 Archive the change. Archiving is what promotes the delta into the
standing spec.
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"./layout": {
"types": "./dist/layout/index.d.ts",
"import": "./dist/layout.js"
},
"./node/runtimes": {
"types": "./dist/node/runtimes/index.d.ts",
"import": "./dist/node/runtimes.js"
}
},
"files": [
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/node/runtimes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Public entry for `@taskless/cli/node/runtimes`.
*
* Where the engine binaries this CLI executes actually live on this host, and
* how to find them. It is the CLI's own resolver and the CLI's own package
* specs — not a copy kept in step, the actual modules — so a consumer that
* installs this package resolves the same ast-grep and the same Vale that
* `taskless check` would run.
*
* ## Why this is published
*
* A service that verifies a generated rule has to execute it, and a rule's
* behaviour is a property of the engine version that runs it, not of the
* version it was written against. ast-grep in particular changes what a capture
* matches across minors: a rule verified under one and executed under another
* can verify clean and then match nothing, which is a rule that passes its gate
* and never fires. Nothing reports that, because both sides succeeded.
*
* The alternative to importing this is pinning the engine version by hand
* alongside the CLI version, in a second place, and keeping the two in step.
* They drift, and the drift is silent in exactly the direction that matters.
* Installing this package and asking it where the binary is makes the engine
* version a consequence of the CLI version rather than a parallel fact: the
* pinned platform packages arrive as this package's own optional dependencies,
* and they move when the pin moves.
*
* ## This entry requires Node, and says so in its path
*
* `node/` is in the specifier deliberately. Resolution spawns each candidate to
* make it identify itself, reads the filesystem, and consults `PATH`, so this
* graph reaches `node:child_process`, `node:fs`, `node:module`, `node:path` and
* `node:url`. It will not run on a Worker or in a browser, and the import path
* says that before a build error has to.
*
* This is the difference between this entry and `@taskless/cli/prompts` and
* `@taskless/cli/layout`, which are host-free and whose builds fail if their
* graphs ever reach a host capability. Every published entry is classified as
* one or the other in `vite.config.ts`, and the build fails on an export
* belonging to neither, so this exemption is a declaration rather than an
* omission.
*
* ## Resolution reports a miss as a value, and each side decides what it means
*
* {@link resolvePlatformBinary} returns `{ path: undefined, tried }` when no
* candidate verified. It does not throw, and that is deliberate rather than
* incidental: `taskless check` runs several engines, and a host without Vale
* installed must lose the Vale rules rather than the whole run. Encoding "not
* found" as a value is what lets a caller choose.
*
* A verifier should choose the opposite. A sandbox whose optional dependency
* did not install resolves `undefined` for the same reason a developer's laptop
* does — nothing is there — but the consequence is not a skipped engine, it is
* a verification that ran no engine and reported success. Fail closed on
* `undefined`, and use `tried` to say where you looked:
*
* ```ts
* import {
* AST_GREP_BINARY,
* resolvePlatformBinary,
* } from "@taskless/cli/node/runtimes";
*
* const { path, tried } = resolvePlatformBinary(AST_GREP_BINARY);
* if (path === undefined) {
* throw new Error(`ast-grep not resolved. Looked in: ${tried.join(", ")}`);
* }
* ```
*
* ## What a resolved path is, and is not
*
* It is a path that exists and that answered `--version` with output matching
* the spec's `identity`, so it is the binary and not a placeholder a failed
* install left behind. It is not a promise about which version answered: the
* first candidate tried is the pinned platform package, but a host that only
* has the engine on `PATH` resolves that instead. Read `--version` yourself if
* the version is what you are relying on.
*/

// The `.js` extensions are deliberate, for the same reason they are on the
// prompts and layout entries: this module is a published entry, so `tsc` copies
// these specifiers verbatim into `dist/node/runtimes/index.d.ts`, where an
// extensionless specifier fails to resolve for a consumer on
// `moduleResolution: node16`/`nodenext`.
export {
findOnPath,
isPlatformBinary,
pathCommandName,
platformPackageName,
resolvePlatformBinary,
type PlatformBinaryResolution,
type PlatformBinarySpec,
} from "../../rules/platform-binary.js";
export { AST_GREP_BINARY } from "../../rules/ast-grep-binary.js";
export { VALE_BINARY } from "../../rules/vale/binary.js";
Loading
Loading