fix(cli): decode ast-grep stderr with a StringDecoder - #126
Conversation
`runAstGrepScan` decoded stderr one chunk at a time with `chunk.toString()`. A multi-byte UTF-8 sequence split across a chunk boundary is decoded as two invalid sequences, both halves become U+FFFD, and the original bytes are unrecoverable by the time the pieces are joined. Each stream now uses a single StringDecoder, flushed with `.end()` on close — the remedy `vale/run.ts` and `verify.ts` already carry. stdout needs nothing: readline decodes across boundaries itself. `runtime/narrow.ts` carried a fourth copy of the same pattern, with the same consequence, and is fixed alongside. Its stderr suffix was gated on `stderrChunks.length > 0`, which the decoder's final flush would have made unconditionally true — an empty string is still a chunk — so the condition now tests the joined text and no message gains a bare `: `. The corrupted text only ever reached an error message, so no scan result was ever wrong. It is the message a user reads when ast-grep rejects a rule file, naming a rule id or a path, which is where a non-ASCII character turns up. No regression test: reproducing this needs stderr split at a chosen byte offset, and both spawn sites resolve their binary internally, leaving no seam to inject a fake process. The two earlier fixes shipped the same way. Fixes #124 Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude /review |
|
Claude finished @thecodedrift's task in 1m 10s —— View job Review:
|
There was a problem hiding this comment.
Pull request overview
This pull request fixes UTF-8 decoding of ast-grep’s stderr in the CLI so multi-byte characters split across chunk boundaries are preserved, improving the reliability of user-facing error messages (without affecting scan correctness).
Changes:
- Use a single
StringDecoder("utf8")forast-grepstderr inrunAstGrepScan, writing perdataevent and flushing with.end()onclose. - Apply the same stderr decoding approach to the runtime “narrow” subprocess, and adjust the error-message suffix logic to avoid appending
:when stderr is empty. - Add a changeset documenting the patch release impact.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/cli/src/rules/scan.ts | Decode ast-grep stderr with a shared StringDecoder and flush on close to avoid UTF-8 boundary corruption. |
| packages/cli/src/rules/runtime/narrow.ts | Decode ast-grep stderr with StringDecoder, flush on close, and avoid empty-suffix error formatting. |
| .changeset/decode-sg-stderr.md | Patch changeset describing the stderr decoding fix and user-visible impact. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ast-grep's stderr was decoded one chunk at a time with
chunk.toString(), so a multi-byte UTF-8 sequence split across a chunk boundary had each half independently replaced with U+FFFD — unrecoverable by the time the pieces were joined. Each stream now uses a singleStringDecoder("utf8"), written per data event and flushed with.end()on close. stdout needs nothing: it is read throughnode:readline, which decodes across boundaries itself.No scan result was ever wrong — the corrupted text only reaches an error message. But it is the message a user reads when ast-grep rejects a rule file, naming a rule id or a path, which is exactly where a non-ASCII character turns up.
A fourth copy, beyond the one reported. The issue names
scan.tsas the third instance aftervale/run.tsandverify.ts.runtime/narrow.tscarried a fourth, identical in pattern and consequence, and is fixed here too — the issue's own reasoning for closing it ("leaving one copy of a defect after fixing its siblings is how it comes back") applies to it unchanged. Happy to split it out if you would rather review it separately.That one had a wrinkle worth a look during review. Its stderr suffix was gated on
stderrChunks.length > 0, and the decoder's final flush pushes an empty string on a stream that ended cleanly — so adding the flush alone would have made the condition unconditionally true and appended a bare:to everyast-grep narrow failed (…)message. The condition now tests the joined text instead:scan.tswas already safe there — it tested the joined string rather than the chunk count.No regression test. Reproducing this requires ast-grep to emit a multi-byte stderr message split at a chosen byte offset, and both spawn sites resolve their binary internally, so there is no seam to inject a fake process without a refactor larger than the fix. The two earlier fixes shipped the same way. If you want this class of bug made unrepresentable rather than fixed per site, that is #99, which was measured and rejected on bundle size.
Fixes #124
Refs #99