ref(cli): resolve ast-grep without an install-time step - #69
Conversation
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
|
@claude /review |
|
Claude finished @thecodedrift's task in 3m 16s —— View job Review complete
Overall assessmentSolid, well-documented refactor. The dependency change is exactly what the description claims ( Left two minor inline notes on
One doc-accuracy note (no code impact): the PR description says confirming the Alpine install "is still open," but the archived Test coverage is reasonable and the tests read as intentional (the docstring in 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. |
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
All three points addressed in
Unreachable 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 Your first note also prompted a fix beyond what was reported. Candidates were selected with — 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
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
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
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
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
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
The CLI no longer ships the
@ast-grep/cliwrapper 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 inoptionalDependencies, andfindSgBinary()already resolved them from our own module context and executed by path. The wrapper was a leftover. Its only job is apostinstallthat hardlinks the platform binary into itself so itsbin: {sg, ast-grep}entries resolve — entries nothing here invokes. It moves todevDependencies, wherescripts/fetch-ast-grep-schema.tsstill reads its version to pick the schema tag.That
postinstallis not hypothetical dead weight.findSgBinary()'s existing comment records why it was written: the hardlink fails underpnpm 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 rootpnpm.onlyBuiltDependenciesto 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.0rather than^0.41.0. The wrapper had been enforcing alignment implicitly by pinning its ownoptionalDependenciesto 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 at0.41.0rather than taking upstream's0.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, thensgandast-greponPATH— and throws an error naming what it tried. Previously it returned a bare"sg"on any miss and letspawn'sENOENTbe 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 (
--versionmust identify as ast-grep) rather than byexistsSync. 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.0or0.45.0, and its Linux packages declarelibc: ["glibc"], so package managers already skip them on musl.Verified on
node:22-alpine(musl, aarch64) rather than inferred from registry metadata:npm installoptionalDependencies(this PR)@ast-grep/*, falls through toPATHOne 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