Skip to content

fix(json,deps): tolerate UTF-8 BOM at the remaining JSON parse sites - #3751

Merged
KuSh merged 11 commits into
rtk-ai:developfrom
kylehgc:submit/bom-json-deps
Aug 30, 2026
Merged

KuSh merged 11 commits into
rtk-ai:developfrom
kylehgc:submit/bom-json-deps

Conversation

@kylehgc

@kylehgc kylehgc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3203 (submit/bom-config-reads) — only the last commit (fix(json,deps): tolerate UTF-8 BOM at the three remaining JSON parse sites) is new; the first two commits are #3203 itself and will drop out of the diff once it merges. Happy to rebase onto develop after #3203 lands.

Follow-up covering the three parse sites @KuSh flagged in the #3203 review as needing the same BOM tolerance, which were scoped out of that PR:

  • src/cmds/system/deps.rs — summarize_package_json_str: parsed package.json with a raw serde_json::from_str(&content)? (also missing .context()). A BOM'd package.json — written by Notepad or PowerShell 5.1 Out-File -Encoding utf8 — made rtk deps hard-fail with a bare expected value at line 1 column 1 that didn't name the file. Now strips the BOM via strip_leading_bom and carries the file path in both read and parse errors.
  • src/cmds/system/json_cmd.rs — filter_json_compact and filter_json_string: rtk json <file> (compact and --keys-only) errored on BOM-prefixed files — the single most likely command to be pointed at human-saved JSON. Both entry points (file and stdin) route through these functions, so both are covered.

Repro

# PowerShell 5.1: Out-File -Encoding utf8 writes a BOM
'{"name": "demo", "version": "1.0.0"}' | Out-File -Encoding utf8 package.json
rtk json package.json   # before: "Failed to parse JSON: expected value at line 1 column 1" (exit 1) — after: prints the JSON
rtk deps .              # before: "expected value at line 1 column 1" (exit 1) — after: summarizes normally

Verified before/after on all three paths, including --keys-only.

Tests

Per site: BOM-prefixed input parses and produces output identical to the plain input; a plain-input test guards existing behavior; the invalid-package.json error now contains the path.

cargo fmt --all && cargo clippy --all-targets && cargo test --all — clean.

🤖 Generated with Claude Code

kylehgc and others added 6 commits July 24, 2026 23:56
rtk init -g --auto-patch aborted with 'expected value at line 1 column 1'
on a settings.json that begins with a UTF-8 BOM — which Notepad and
PowerShell 5.1's Out-File -Encoding utf8 both write by default. The hook
was never installed and the error blamed the user's (valid) JSON. Worse,
the statusline patch path fell back to json!({}) on the parse failure
and would silently wipe existing settings on write-back.

strip_leading_bom already existed in hook_cmd.rs, guarding all four hook
stdin entry points; the file-read paths never got the same treatment.
Promote it to core::utils and apply it at every site that parses JSON a
human may have hand-edited: settings.json / hooks.json readers in init.rs
(12 sites), hook_check.rs, integrity.rs, and permissions.rs.

TOML sites (config.toml, filter DSL) are deliberately untouched: the toml
crate tolerates a leading BOM, pinned by a new regression test. The BOM
is dropped on write-back — RFC 8259 s8.1 forbids adding one to JSON.

Fork issue: #58 (stays open until the upstream PR is submitted)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment

Review round on PR #62 found two hand-edited JSON parse sites the sweep
missed — read_composer_bin_dir (core/utils.rs) and
parse_global_json_mtp_mode (dotnet_cmd.rs, where BOMs are most common) —
plus a test comment claiming the toml crate rejects BOMs when it in fact
tolerates them (which is the very reason TOML sites are untouched). Also
adds the BOM+invalid-JSON edge test the spec asked for: stripping must
not mask genuinely broken JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sites

Follow-up to upstream rtk-ai#3203 (fork PR #62), which added
strip_leading_bom() for config reads. KuSh's review flagged three more
parse sites that hard-fail on BOM-prefixed files, scoped out of rtk-ai#3203
into this follow-up:

- deps.rs summarize_package_json_str: a package.json saved by Notepad
  or PowerShell 5.1 Out-File killed `rtk deps` with a bare "expected
  value at line 1 column 1" (no file path). Now BOM-tolerant, with
  .context() naming the file on both read and parse failures.
- json_cmd.rs filter_json_compact / filter_json_string: `rtk json`
  (both compact and --keys-only) errored on BOM-prefixed files -- the
  most likely command to be pointed at human-saved JSON.

Unit tests per site: BOM input parses identically to plain input;
invalid package.json errors now carry the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n_str wrapper

Review response for upstream rtk-ai#3203 (KuSh):

- patch_gemini_settings: a non-BOM parse failure (trailing comma, comment,
  truncated file) now propagates with .context() naming the file, matching
  the Claude path, instead of unwrap_or(json!({})) silently replacing the
  user's settings with an empty object on write-back. Regression test pins
  both the error and that the file on disk is untouched.
- New core::utils::from_json_str: BOM-tolerant serde_json::from_str with
  Deserialize<'a> (zero-copy preserved, tested with a borrowed type).
  Every JSON parse site in this PR's diff now routes through it -- init.rs,
  hook_check.rs, permissions.rs, integrity.rs, dotnet_cmd.rs, and the
  composer.json site in utils.rs -- so the policy is one discoverable
  helper instead of a per-site convention. strip_leading_bom stays pub as
  the primitive for non-serde consumers.

The three out-of-diff sites KuSh flagged (deps.rs, json_cmd.rs) are the
follow-up PR rtk-ai#3751, as requested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n_str

The from_json_str wrapper landed on the base branch (rtk-ai#3203 review round)
after this branch's fix was written; convert its three sites from the raw
serde_json::from_str(strip_leading_bom(..)) combination to the wrapper so
the whole series states the policy one way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kylehgc

kylehgc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Updated: merged the current #3203 branch (which now carries the from_json_str wrapper from its review round) and converted this PR's three sites from the raw serde_json::from_str(strip_leading_bom(..)) combination to the wrapper, so the whole series states the policy one way. Stacked on #3203 — the first two commits here are its; the diff net of that base is the three-site fix plus the wrapper conversion. Gate: fmt clean, clippy zero warnings, 2502 tests passing.

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full cumulative diff (5 commits, 10 files). Verified on a clean worktree at beee9df: cargo fmt --all -- --check clean, cargo clippy --all-targets zero warnings, cargo test --all 2568 passed / 0 failed.

The core change is sound. from_json_str keeps the zero-copy <'a, T: Deserialize<'a>> signature, and the strip_leading_bom -> trim().is_empty() ordering is correct at every site that needed it: U+FEFF is Cf, not whitespace, so the empty-file guard has to run after stripping, and the new BOM-only / BOM-plus-invalid-JSON tests pin exactly that. Panic safety on the write paths is intact, and the remaining serde_json::from_str call sites in the tree are command-output parsers (aws, pnpm, vitest, ruff, ...) or RTK-written state (trust.rs), correctly left alone.

Three things to address before merge, see the inline comments. The hook_cmd.rs one matters most, given the PR is scoped as "the remaining parse sites".

Comment thread src/hooks/init.rs
Comment thread src/hooks/hook_cmd.rs
Comment thread src/cmds/system/json_cmd.rs
…M on claude/gemini hook stdin and rtk json fallback

Review response for upstream rtk-ai#3751 (KuSh):

- patch_gemini_settings: a parse failure no longer aborts rtk init after
  the hook script, GEMINI.md, and integrity baseline are already written.
  It now warns with the file and error, prints the same manual-setup
  instructions as PatchMode::Skip (shared helper), and continues -- still
  never writing over the unparsed file.
- run_gemini and run_claude strip a leading BOM from the hook stdin
  payload like the copilot/cursor/droid paths. run_gemini's parse logic
  is extracted into run_gemini_inner, called by production and tests
  alike, so the BOM test exercises the real path rather than a mirrored
  copy.
- rtk json strips the BOM once at the read (shared render_json helper),
  before never_worse, so the raw fallback for already-compact files no
  longer re-emits the BOM into piped output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kylehgc

kylehgc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed as of b6f354c.

Half-installed state: patch_gemini_settings now treats an unparseable settings file the way PatchMode::Skip does — warns with the file path and parse error, prints the same manual-setup instructions (factored into a shared helper), and lets the rest of the install report success. The no-overwrite guarantee is unchanged: the parse-failure path returns before any mutation, and the regression test now asserts Ok + byte-identical file + the warn path.

Hook stdin: run_gemini and run_claude both strip the BOM now, matching the copilot/cursor/droid paths — all five entry points are uniform. run_gemini's parse logic moved into a run_gemini_inner that production and the new BOM test share, so the test exercises the real path (a dropped strip breaks the actual hook, not a mirrored copy).

never_worse fallback: the BOM is stripped once at the read, in a shared render_json used by both the file and stdin paths, before the compare — so the raw fallback for an already-compact file emits BOM-free output and rtk json foo.json | jq . works on exactly the case you named. Pinned by a test forcing the fallback path with a minified BOM'd input.

Gate: fmt clean, clippy zero warnings, 2516 tests passing.

kylehgc added a commit to kylehgc/rtk that referenced this pull request Aug 29, 2026
…appens

Encodes the protocol validated on upstream PRs rtk-ai#3199/rtk-ai#3751: an
uncontaminated run of upstream's own .claude/agents/code-reviewer.md
prompt, plus the empirical techniques the maintainer layers on top --
fixture A/B against the merge base, prior-probe re-runs, bisect
attribution -- and the fix-round discipline (load-bearing proof by
revert, total contracts over examples, no documented deferrals, no
scope-creep riders). do-issue Phase 6 now delegates its second review
pass to /preclear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround — I re-reviewed b6f354c and all three earlier findings are properly fixed; I've resolved those threads. The run_gemini_inner extraction in particular is the right shape: routing production run_gemini through it (rather than mirroring the parse in a test) means a dropped BOM strip fails for real.

Re-ran the gate on the head: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --all all clean on a default environment.

One blocker and three non-blocking notes, all on the new commit:

  • Blocking — test_gemini_strips_utf8_bom is non-hermetic. It reads the developer's real ~/.gemini/settings.json; I reproduced a red test on a machine that uses Gemini CLI. Details inline.
  • Non-blocking: the lost serde diagnostic in run_gemini, the "installed" summary printed after a failed settings patch, and an avoidable full copy of the input in render_json. Plus one nitpick on the tracking baseline.

Happy to see the rest land as-is if you'd rather keep the PR tight — only the first one needs to change before merge.

Comment thread src/hooks/hook_cmd.rs Outdated
Comment thread src/hooks/hook_cmd.rs Outdated
Comment thread src/hooks/init.rs Outdated
Comment thread src/cmds/system/json_cmd.rs Outdated
Comment thread src/cmds/system/json_cmd.rs
… honest init summary, zero-copy fallback

Review response for upstream rtk-ai#3751 (KuSh round 2):

- test_gemini_strips_utf8_bom is hermetic: rules are injected through a
  shared run_gemini_inner_impl so the test no longer reads the real
  ~/.gemini/settings.json, while production and the test still exercise
  the identical parse/strip/render path. Verified under KuSh's exact
  dirty-HOME reproduction (allow-listed git status): full suite green.
- run_gemini_inner returns serde_json::Result, so a malformed payload
  reports the real serde diagnostic (line/column) under the context
  message again.
- After a settings.json parse failure, the init summary now prints
  'settings.json: NOT patched (existing file could not be parsed; see
  warning above)' instead of unconditional success text; exit stays 0
  per the graceful-degrade design. PatchMode::Skip is unchanged.
- render_json returns Cow<str>: the never_worse raw-fallback path
  borrows instead of allocating a third full copy of the input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kylehgc

kylehgc commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

All addressed as of ed8b0eb — the blocker plus the three notes; one nit deliberately declined, reasoning below.

The hermetic test (blocker): went with your second option — rules injected through a shared run_gemini_inner_impl, the same pattern as gemini_render/decide_with_rules, with production run_gemini_inner and the test-only run_gemini_inner_with_rules both routing through the one parse/strip/render implementation. So the test is deterministic under any HOME while still exercising the identical production BOM path — dropping the strip inside the shared impl fails it. Reproduced your exact scenario (a HOME with run_shell_command(git status) allow-listed) and ran the full suite under it: green, including this test.

Serde diagnostic: run_gemini_inner returns serde_json::Result<String>, so the line/column detail is chained under the context message again; pinned by a test asserting the diagnostic survives.

Honest summary: patch_gemini_settings signals the parse-failure outcome distinctly, and the summary now prints settings.json: NOT patched (existing file could not be parsed; see warning above) instead of the unconditional installed line. Kept exit 0 — the point of the graceful-degrade was that the rest of the install genuinely succeeded — but stdout no longer claims more than happened. PatchMode::Skip is untouched.

Fallback copy: render_json returns Cow<'_, str>; the raw-fallback branch borrows, only the genuinely-filtered branch allocates, and the test asserts Cow::Borrowed on the fallback path.

The timer.track baseline — acknowledged and deliberately left as-is: the baseline is meant to be the bytes the raw command would have emitted, and cat on a BOM'd file genuinely emits those bytes; counting them keeps the metric consistent with how every other filter accounts. Your framing ("arguably correct as-is") matches our read, so we kept the current semantics rather than special-casing 3 bytes.

Gate: fmt clean, clippy zero warnings, 2517 tests passing — under both a clean HOME and your dirty-HOME reproduction.

KuSh and others added 3 commits August 30, 2026 02:54
…ip, name files in deps read errors

Review follow-ups for rtk-ai#3751 (round 3):

- render_json compares the `&str` fat pointers instead of `as_ptr()` when
  deciding which side never_worse handed back. An address-only comparison
  also matches a sub-slice of `content`, so a future truncating never_worse
  would take the borrow branch and silently re-expand the output, with no
  test failure.
- run_droid and the droid test entry point now share `droid_payload`, which
  strips the BOM, trims, and reports an empty payload as nothing to do.
  Droid's BOM strip had zero coverage: the test path re-parsed without it,
  so deleting the strip left every test green. Verified by mutation --
  removing it now fails test_droid_strips_utf8_bom.
- The four remaining bare `fs::read_to_string(path)?` calls in deps.rs gained
  the same `.with_context(...)` the package.json read already had, so
  `chmod 000 Cargo.toml && rtk deps .` names the file instead of reporting a
  bare "Permission denied (os error 13)".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict in src/hooks/hook_cmd.rs: develop added the Mistral Vibe hook
right where this branch refactored run_gemini's tail into
run_gemini_inner_impl. Kept both -- the refactored gemini arm and
develop's Vibe section verbatim -- and dropped print_allow/print_gemini,
which the refactor already replaced with gemini_json and which nothing
else calls.
run_vibe landed on develop while this branch was in review, making it the
sixth hook stdin entry point and the only one that did not strip a leading
UTF-8 BOM. Windows hosts prepend one (confirmed for Cursor); without the
strip, serde_json rejects the payload, run_vibe_inner logs to stderr and
returns None, and every command silently stops being rewritten -- the same
failure the other five entry points already guard against.

Verified by mutation: removing the strip fails test_vibe_strips_utf8_bom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Three rounds in, everything raised is addressed and the BOM coverage is now genuinely complete: all six hook stdin entry points strip, and every non-test serde_json::from_str over a human- or tool-written file routes through from_json_str. The sites deliberately left raw — the trust.rs trust store, analytics JSONL, nested toolArgs strings, fixtures — are RTK/machine-written and correctly untouched.

Worth restating what the patch_gemini_settings change actually buys: the old unwrap_or(json!({})) would silently overwrite an unparseable ~/.gemini/settings.json with a fresh {} plus the hook, destroying user config. That's a real data-loss fix, not just BOM tolerance.

Two things I liked in the review responses: routing production run_gemini through run_gemini_inner_impl rather than mirroring the parse in a test, so a dropped strip fails for real; and returning Ok(true) from patch_gemini_settings only on the parse-failure path, so the summary can distinguish it from Skip, already-patched, declined-at-prompt and dry-run.

Disclosure — the last three commits are mine, pushed via maintainer edit rather than sent back for another round:

  • 9d78af2 — the three round-3 findings: std::ptr::eq on the &str fat pointers instead of as_ptr() (an address-only compare also matches a sub-slice, so a future truncating never_worse would silently re-expand the output); run_droid and its test entry point now share a droid_payload helper, closing a gap where droid's BOM strip had zero coverage; and .with_context(...) on the four remaining bare reads in deps.rs.
  • 3865a84 — merge of develop. The conflict was in hook_cmd.rs only: develop added the Vibe hook exactly where this branch refactored run_gemini's tail. Kept both sides, dropped the now-unused print_allow/print_gemini.
  • ac3389a — run_vibe landed on develop mid-review as the sixth hook stdin entry point and the only one not stripping, so merging develop in would have quietly broken the invariant this PR exists to establish. One line plus a test.

Both new tests are mutation-verified: deleting the strip fails test_droid_strips_utf8_bom / test_vibe_strips_utf8_bom and nothing else. Gate on ac3389a: cargo fmt --all -- --check clean, cargo clippy --all-targets -- -D warnings clean, cargo test --all 2735 passed / 0 failed.

Please sanity-check those three commits — happy to drop or rework any of them if you'd rather they were separate.

Two follow-ups filed from this review, both pre-existing and out of scope here: #3767 (one malformed manifest aborts the whole rtk deps scan instead of degrading) and #3768 (test_resolved_command_executes_known_command asserts on rustup's exit status and fails whenever HOME is redirected).

@KuSh KuSh changed the title fix(json,deps): tolerate UTF-8 BOM at the three remaining JSON parse sites fix(json,deps): tolerate UTF-8 BOM at the remaining JSON parse sites Aug 30, 2026
@kylehgc

kylehgc commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Sanity-checked all three commits — keep them all, and thanks for pushing directly rather than spending another round.

  • 9d78af2: the fat-pointer compare is strictly better than the as_ptr() version we wrote — comparing the whole slice means a future sub-slice-returning never_worse can't masquerade as the raw input. And droid_payload closes exactly the mirrored-copy test trap we'd just fixed for gemini; fair catch that we didn't sweep the other agents for the same pattern after that lesson.
  • 3865a84: verified the merge kept everything intact on our side — run_gemini_inner, the "NOT patched" summary path, and all strip sites present post-merge.
  • ac3389a: right call — landing the merge without it would have broken the six-entry-point invariant this PR exists to establish.

Independently re-ran the gate on ac3389a here: fmt clean, clippy zero warnings, full suite 0 failed. Nothing to drop or rework — and thanks for the thorough rounds on this one; the review made it a much better change than the one we started with.

@KuSh
KuSh merged commit 7e7be16 into rtk-ai:develop Aug 30, 2026
11 of 12 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Aug 30, 2026
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.

2 participants