Every subprocess in the CLI is spawned by hand with node:child_process, and the surrounding scaffolding — timeout, kill, stream decoding, error shaping — is re-implemented per call site. Two sites have grown full independent copies of it, and one bug has already been fixed by hand that a library would have made unrepresentable.
Raised in review on #71, on two separate threads:
Do we gain anything from switching to execa here? It's a little more feature complete, but I'd like to see if there are tradeoffs worth it WRT stdout/err capture
We should really prefer async alternatives to sync options when possible
These turn out to be the same change. execa is promise-based, so adopting it converts the sync path as a consequence rather than as a second effort.
What exists today
Ten subprocess call sites:
| File |
Call |
src/rules/scan.ts:145 |
spawn — ast-grep, --json=stream, read line-by-line via node:readline |
src/rules/verify.ts:185 |
spawn — ast-grep test |
src/rules/vale/run.ts:110 |
spawn — Vale, with hand-rolled timeout + SIGKILL + settle guard |
src/rules/runtime/invoke.ts:95 |
spawn — runtime harness, second independent copy of the same scaffolding |
src/rules/runtime/invoke.ts:108 |
spawn — Windows taskkill /pid /T /F tree-kill branch |
src/rules/runtime/narrow.ts:43 |
spawn — ast-grep narrowing |
src/rules/platform-binary.ts:139 |
spawnSync — --version identity probe, in a loop over candidates |
src/util/git-remote.ts:14,108 |
execFile — git |
src/auth/token.ts:131 |
execFile — git ls-files |
Why this is worth a dependency
The scaffolding is duplicated, and the duplication is already load-bearing. vale/run.ts and runtime/invoke.ts each independently implement timeout → kill → settle-once. invoke.ts additionally carries a Windows tree-kill branch that vale/run.ts does not, so the two disagree about what "terminate this child" means depending on which engine you are in. execa's timeout and forceKillAfterDelay cover both, cross-platform, once.
A decoding bug was already fixed by hand here. vale/run.ts accumulated stdout with chunk.toString() per Buffer, which corrupts a multi-byte UTF-8 sequence split across a chunk boundary — reachable, since Vale lints prose with curly quotes and em dashes. Worst case the corruption lands inside JSON string escaping and a clean run is reported as Vale produced output that is not JSON. It was fixed with an explicit StringDecoder per stream. Correct stream decoding is table stakes for a subprocess library; hand-rolling it is how that class of bug recurs at the next call site.
Errors are shaped per site. Each spawn invents its own failure message. execa's errors carry the command, exit code, signal, and captured stderr by construction.
It resolves the sync question without a separate migration. platform-binary.ts probes candidates with spawnSync in a loop — up to six spawns, each with a 5s timeout, blocking the event loop. Going async there ripples findSgBinary() through scan.ts, verify.ts, and runtime/narrow.ts, one of which sits inside a new Promise executor. That ripple is the same work as adopting execa, so doing them together is strictly cheaper than doing them in sequence.
Costs, stated plainly
- A runtime dependency, not a dev one. The lib build bundles everything except node builtins, so execa and its transitive deps land in the shipped
dist/index.js (currently ~490 kB). Worth measuring the delta before committing.
- Eight call sites change, plus the
findSgBinary() signature ripple through three callers and its memoization (a cached value becomes a cached promise).
- Streaming needs care at one site.
scan.ts consumes ast-grep's --json=stream incrementally through readline. execa supports line iteration, but this is the one site where the current approach is already correct and idiomatic, so it should be converted deliberately rather than mechanically.
Suggested scope
Its own PR, not folded into the Vale stack. Deliberately kept separate so the "does this earn a runtime dependency" question gets answered on its own terms rather than inside a 2000-line diff.
Refs #71
Every subprocess in the CLI is spawned by hand with
node:child_process, and the surrounding scaffolding — timeout, kill, stream decoding, error shaping — is re-implemented per call site. Two sites have grown full independent copies of it, and one bug has already been fixed by hand that a library would have made unrepresentable.Raised in review on #71, on two separate threads:
These turn out to be the same change. execa is promise-based, so adopting it converts the sync path as a consequence rather than as a second effort.
What exists today
Ten subprocess call sites:
src/rules/scan.ts:145spawn— ast-grep,--json=stream, read line-by-line vianode:readlinesrc/rules/verify.ts:185spawn— ast-greptestsrc/rules/vale/run.ts:110spawn— Vale, with hand-rolled timeout +SIGKILL+ settle guardsrc/rules/runtime/invoke.ts:95spawn— runtime harness, second independent copy of the same scaffoldingsrc/rules/runtime/invoke.ts:108spawn— Windowstaskkill /pid /T /Ftree-kill branchsrc/rules/runtime/narrow.ts:43spawn— ast-grep narrowingsrc/rules/platform-binary.ts:139spawnSync—--versionidentity probe, in a loop over candidatessrc/util/git-remote.ts:14,108execFile— gitsrc/auth/token.ts:131execFile—git ls-filesWhy this is worth a dependency
The scaffolding is duplicated, and the duplication is already load-bearing.
vale/run.tsandruntime/invoke.tseach independently implement timeout → kill → settle-once.invoke.tsadditionally carries a Windows tree-kill branch thatvale/run.tsdoes not, so the two disagree about what "terminate this child" means depending on which engine you are in. execa'stimeoutandforceKillAfterDelaycover both, cross-platform, once.A decoding bug was already fixed by hand here.
vale/run.tsaccumulated stdout withchunk.toString()perBuffer, which corrupts a multi-byte UTF-8 sequence split across a chunk boundary — reachable, since Vale lints prose with curly quotes and em dashes. Worst case the corruption lands inside JSON string escaping and a clean run is reported asVale produced output that is not JSON. It was fixed with an explicitStringDecoderper stream. Correct stream decoding is table stakes for a subprocess library; hand-rolling it is how that class of bug recurs at the next call site.Errors are shaped per site. Each spawn invents its own failure message. execa's errors carry the command, exit code, signal, and captured stderr by construction.
It resolves the sync question without a separate migration.
platform-binary.tsprobes candidates withspawnSyncin a loop — up to six spawns, each with a 5s timeout, blocking the event loop. Going async there ripplesfindSgBinary()throughscan.ts,verify.ts, andruntime/narrow.ts, one of which sits inside anew Promiseexecutor. That ripple is the same work as adopting execa, so doing them together is strictly cheaper than doing them in sequence.Costs, stated plainly
dist/index.js(currently ~490 kB). Worth measuring the delta before committing.findSgBinary()signature ripple through three callers and its memoization (a cached value becomes a cached promise).scan.tsconsumes ast-grep's--json=streamincrementally throughreadline. execa supports line iteration, but this is the one site where the current approach is already correct and idiomatic, so it should be converted deliberately rather than mechanically.Suggested scope
Its own PR, not folded into the Vale stack. Deliberately kept separate so the "does this earn a runtime dependency" question gets answered on its own terms rather than inside a 2000-line diff.
vale/run.tsandruntime/invoke.ts(highest value — deletes both copies of the timeout/kill/settle scaffolding and the Windows tree-kill divergence)platform-binary.tsoffspawnSync, and makefindSgBinary()async through its three callersscan.ts,verify.ts,narrow.tsgit-remote.ts/token.tsfor last; they are simpleexecFilecalls with the least to gainStringDecoderfix invale/run.tsis genuinely subsumed rather than merely deletedRefs #71