Skip to content

ref(cli): resolve ast-grep without an install-time step - #69

Merged
thecodedrift merged 9 commits into
mainfrom
openspec/direct-sg-platform-deps
Jul 29, 2026
Merged

ref(cli): resolve ast-grep without an install-time step#69
thecodedrift merged 9 commits into
mainfrom
openspec/direct-sg-platform-deps

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Jul 28, 2026

Copy link
Copy Markdown
Member

The CLI no longer ships the @ast-grep/cli wrapper to consumers, and finding the ast-grep binary no longer depends on anything happening at install time.

Most of this migration was already done: the seven @ast-grep/cli-<platform> packages were already declared in optionalDependencies, and findSgBinary() already resolved them from our own module context and executed by path. The wrapper was a leftover. Its only job is a postinstall that hardlinks the platform binary into itself so its bin: {sg, ast-grep} entries resolve — entries nothing here invokes. It moves to devDependencies, where scripts/fetch-ast-grep-schema.ts still reads its version to pick the schema tag.

That postinstall is not hypothetical dead weight. findSgBinary()'s existing comment records why it was written: the hardlink fails under pnpm dlx's strict dependency isolation and leaves a placeholder text file where the binary should be. It also needs an opt-in entry in the root pnpm.onlyBuiltDependencies to run at all. With it out of the runtime dependency set, the binary is present purely by dependency resolution.

Two behavior changes worth reviewing closely.

Platform packages are now pinned exactly at 0.41.0 rather than ^0.41.0. The wrapper had been enforcing alignment implicitly by pinning its own optionalDependencies to its exact version; without it, two hosts could resolve different ast-grep versions against the same rules and legitimately disagree about findings — a divergence that shows up as inconsistent results, not as an install error. The pin is deliberately held at 0.41.0 rather than taking upstream's 0.45.0, so that if ast-grep behavior shifts, this change isn't a candidate explanation. Bumping is worth doing separately.

findSgBinary() now searches every candidate before giving up — the platform package, node_modules/.bin, then sg and ast-grep on PATH — and throws an error naming what it tried. Previously it returned a bare "sg" on any miss and let spawn's ENOENT be the message, produced by a caller that couldn't say where it had looked. All three call sites already route through it, so they're unchanged; they just get a better answer, or a real error.

Each candidate is also verified by running it (--version must identify as ast-grep) rather than by existsSync. That closes the hole this resolver exists for: the wrapper's failed hardlink leaves a placeholder text file at the binary's path, which an existence check accepts happily. Asking the candidate to identify itself is the only check that tells the real binary from a file merely sitting where it belongs. Resolution is cached per process, since the search now spawns a subprocess per candidate.

Alpine gets better, though the diff reads like it might get worse. Removing the wrapper removes a fallback, so the fair question is what happens where no platform package exists. Upstream publishes no musl build at either 0.41.0 or 0.45.0, and its Linux packages declare libc: ["glibc"], so package managers already skip them on musl.

Verified on node:22-alpine (musl, aarch64) rather than inferred from registry metadata:

npm install
Wrapper as a hard dependency (today) exits 1 — "Failed to move @ast-grep/cli binary into place."
Platform packages as optionalDependencies (this PR) exits 0, installs no @ast-grep/*, falls through to PATH

One task stays open by necessity: verifying the resolved path under a real pnpm dlx, which needs a published build and so lands after release.

This also sets the pattern that Vale binary packaging will follow. For ast-grep it meant deleting a layer; for Vale it will mean publishing platform packages that don't exist yet.

Typecheck, lint, and the full suite pass.

Fixes OSS-23

thecodedrift and others added 7 commits July 27, 2026 18:07
We depend on @ast-grep/cli for a bin shim we never invoke. All three
call sites already go through findSgBinary, which resolves the
platform package directly and execs by path — the wrapper contributes
only a postinstall hardlink and a .bin fallback.

That hardlink has already failed here: findSgBinary's comment exists
because pnpm dlx's strict isolation leaves a placeholder text file in
place of the binary. The wrapper also needs a dependency build script
to run at all, which pnpm 10 blocks by default.

Upstream publishes the platform packages independently — os/cpu
declared, no scripts, no bin — so depending on them directly deletes
a layer rather than adding one, and matches the model
add-vale-binary-packages establishes for Vale.

The one path that could regress is musl: findSgBinary maps every Linux
to -gnu, so the shim may be doing real work on Alpine today. Verifying
that is called out rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…search

Group 1 changed the shape of this change. The seven platform packages
are already declared in optionalDependencies — only the wrapper is
left over — and musl inverts from a risk to an improvement: no musl
package exists at any version, the gnu packages declare libc, and
today's postinstall exits 1 on Alpine.

The wrapper moves to devDependencies rather than disappearing:
fetch-ast-grep-schema.ts reads its version for the schema tag, which
is a build-time need. Consumers stop installing it either way, so the
pnpm dlx failure mode leaves the shipped product.

Resolution now exhausts every known location — platform package,
node_modules/.bin, sg and ast-grep on PATH — and throws naming what it
tried, instead of returning a bare "sg" for spawn to fail on. If
there's no sg, there's no sg; we just have better and worse places to
look first.

Pin held at 0.41.0 deliberately, not bumped to 0.45.0, so the swap
stays independently verifiable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
The seven platform packages were already declared; only the wrapper
was left over. It moves to devDependencies, where fetch-ast-grep-schema
still reads its version, so consumers stop installing it and its
postinstall — the one that leaves a placeholder text file under pnpm
dlx — leaves the shipped product entirely.

Platform packages pinned exactly at 0.41.0. The wrapper was enforcing
version alignment implicitly by pinning its own optionalDependencies;
with it gone from runtime, carets would let hosts resolve different
ast-grep versions against the same rules. Held at 0.41.0 rather than
bumped to 0.45.0 so this stays structural and independently verifiable.

findSgBinary now exhausts every candidate — platform package,
node_modules/.bin, sg then ast-grep on PATH — and throws naming what it
tried. It previously returned a bare "sg" and let spawn's ENOENT be the
error message, from a caller that could not say where it looked.

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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
The resolution rewrite shipped without tests. These cover the contract
rather than ast-grep itself: the platform package wins over a decoy sg
on PATH, resolution returns an absolute path, and it survives an empty
PATH.

Also asserts the dependency shape the wrapper used to guarantee
implicitly — every platform package pinned to one exact version, the
wrapper absent from what consumers install, and the devDependency
matching the shipped binaries. A mixed set would have hosts disagreeing
about findings rather than failing to install.

Alpine verified empirically on node:22-alpine (musl/aarch64): with the
platform packages optional, npm install exits 0 and installs none of
them; with the wrapper as a hard dependency it exits 1, "Failed to move
@ast-grep/cli binary into place."

buildPath is kept and documented — it no longer locates the binary, but
still shapes the spawned process's environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Three requirements land in the cli capability: platform packages
instead of the wrapper, no install-time step for the binary, and
resolution that searches every known location before failing.

Two tasks stay open by necessity — verifying under `pnpm dlx` and
against a real `npm pack` both need a published build, so they land
after release rather than in this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
@thecodedrift
thecodedrift marked this pull request as ready for review July 28, 2026 02:46
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 16s —— View job


Review complete

  • Gather context (read diff, changed files, CLAUDE.md conventions)
  • Understand the request (code review of PR ref(cli): resolve ast-grep without an install-time step #69)
  • Review packages/cli/src/rules/scan.ts changes (findSgBinary logic)
  • Review packages/cli/package.json dependency changes
  • Review packages/cli/scripts/fetch-ast-grep-schema.ts
  • Review new test file packages/cli/test/sg-binary.test.ts
  • Review OpenSpec docs for consistency with implementation
  • Post final review

Overall assessment

Solid, well-documented refactor. The dependency change is exactly what the description claims (@ast-grep/clidevDependencies, platform packages pinned to exact 0.41.0, fetch-ast-grep-schema.ts reading from devDependencies), and findSgBinary()'s new exhaustive search with a descriptive failure is a clear improvement over the old bare-"sg"-and-let-ENOENT-explain-it behavior. All three call sites (scan.ts, verify.ts, narrow.ts:43) already route through findSgBinary()/buildPath() and are unaffected. The pnpm-lock.yaml diff matches the package.json changes exactly, and no other workspace package depends on @ast-grep/cli or sg/ast-grep directly, so this is safely scoped to packages/cli.

Left two minor inline notes on scan.ts:

  • The child.on("error", ...) ENOENT handler in runAstGrepScan (not touched by this diff, so no inline comment landed there) is now effectively unreachable — findSgBinary() already validates existsSync and throws its own descriptive error before spawn is called. If it ever does fire (e.g. a TOCTOU race), its message ("Is @ast-grep/cli installed?") points at a wrapper that's no longer in the resolution chain at all. Worth a follow-up to update or remove it.
  • A small asymmetry in the new candidate list: the node_modules/.bin entry only checks the sg/sg.exe name, while the PATH entries check both sg and ast-grep names. Low risk in practice since the wrapper's bin field declares both names for the same target, but worth a look for consistency.

One doc-accuracy note (no code impact): the PR description says confirming the Alpine install "is still open," but the archived tasks.md (2b.4/3.5) shows that verification as already done — confirmed on node:22-alpine (musl, aarch64) — along with the buildPath() question (4.1, "kept," rationale recorded). The only item actually still open per tasks.md is 3.2 (verifying the resolved path under a real pnpm dlx against a published build), which the PR body doesn't call out specifically. Might be worth tightening the PR description before merge so reviewers aren't chasing already-resolved questions.

Test coverage is reasonable and the tests read as intentional (the docstring in sg-binary.test.ts explains what's covered and why). The author's own tasks.md already flags that the PATH-fallback and exhausted-search branches aren't unit-tested (would need module mocking) — a reasonable, acknowledged trade-off rather than an oversight.

No security, correctness, or performance issues found. Per instructions, I did not run typecheck/lint/test locally or check CI status — that's handled by CI on the PR itself.

Comment thread packages/cli/src/rules/scan.ts
existsSync accepts the exact artifact this resolver exists to avoid.
When the @ast-grep/cli wrapper's hardlink fails under pnpm dlx it
leaves a placeholder TEXT FILE at the binary's path — readable,
present, and not ast-grep. Both the old code and the new search would
have returned it.

Each candidate is now asked to identify itself (--version must report
ast-grep), so a file merely sitting where the binary belongs is skipped
rather than executed. Resolution is cached per process since the search
spawns a subprocess per candidate.

Also from review: check both `sg` and `ast-grep` under node_modules/.bin
to match the PATH search, and stop pointing the ENOENT handler at
@ast-grep/cli, which is no longer in the resolution chain — if it fires
now it means the binary vanished between resolution and spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 3m 16s — Review complete…"
#69 (comment)

All three points addressed in d077898.

node_modules/.bin asymmetry — fixed; it now checks both sg and ast-grep, matching the PATH search, with a comment explaining why either name may be linked.

Unreachable ENOENT handler — you were right that its message pointed at a package no longer in the resolution chain. It now reports that the binary vanished between resolution and execution, which is the only way it can still fire.

Stale PR description — correct, and my error: I wrote the body before running the Alpine verification and never updated it. The description now carries the measured result (wrapper as a hard dependency exits 1 on node:22-alpine; platform packages as optionalDependencies exit 0) and names 3.2 as the single remaining item, since it needs a published build.

Your first note also prompted a fix beyond what was reported. Candidates were selected with existsSync, which accepts precisely the artifact this resolver exists to avoid — the failed hardlink leaves a placeholder text file at the binary's path. Both the old code and my search would have returned it. Each candidate is now asked to identify itself via --version, with tests covering a placeholder file, a non-ast-grep executable, and a missing path. 378 → 382 tests.

— AI Coding Agent

resetSgBinaryCache existed solely so tests could clear process state —
production surface area carrying no production purpose. Tests use
vi.resetModules() and re-import instead, which also makes each case
genuinely re-resolve rather than trusting a reset.

isAstGrepBinary stays exported: it is meaningful behavior worth testing
directly, and add-vale-rule-engine wants the same predicate shape for
Vale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
@thecodedrift
thecodedrift merged commit 652766b into main Jul 29, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the openspec/direct-sg-platform-deps branch July 29, 2026 00:37
thecodedrift added a commit that referenced this pull request Jul 29, 2026
The reference table only documented TSKL-, which reads as though it's
the only bare identifier the integration understands. It isn't — OSS-23
linked and moved to In Review on PR creation for #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
thecodedrift added a commit that referenced this pull request Jul 31, 2026
The reference table only documented TSKL-, which reads as though it's
the only bare identifier the integration understands. It isn't — OSS-23
linked and moved to In Review on PR creation for #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
thecodedrift added a commit that referenced this pull request Aug 3, 2026
The reference table only documented TSKL-, which reads as though it's
the only bare identifier the integration understands. It isn't — OSS-23
linked and moved to In Review on PR creation for #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
thecodedrift added a commit that referenced this pull request Aug 6, 2026
The reference table only documented TSKL-, which reads as though it's
the only bare identifier the integration understands. It isn't — OSS-23
linked and moved to In Review on PR creation for #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
thecodedrift added a commit that referenced this pull request Aug 9, 2026
The reference table only documented TSKL-, which reads as though it's
the only bare identifier the integration understands. It isn't — OSS-23
linked and moved to In Review on PR creation for #69.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant