fix: pin rendered numbers to one locale so graft can read its own savings footer (#338) - #345
fix: pin rendered numbers to one locale so graft can read its own savings footer (#338)#345Ercaner1988 wants to merge 3 commits into
Conversation
🌱 graft blast radius3 areas changed → 5 areas can be affected. 10 dependent symbols, depth 2. flowchart TB
A0(("Workspace Federation<br/>4 symbols"))
A1(("MCP Tool Invocation<br/>2 symbols"))
A2(("Claude UI Integration<br/>2 symbols"))
A3(("CLI Entry Point<br/>1 symbol"))
A4(("Grep Search Formatting<br/>1 symbol"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2,A3,A4 reached;
Who knows this code — 3 people across 8 areas
Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no All 10 dependent symbols, grouped by areaWorkspace Federation — 4 symbols in 4 files
MCP Tool Invocation — 2 symbols in 1 file
Claude UI Integration — 2 symbols in 2 files
CLI Entry Point — 1 symbol in 1 file
Grep Search Formatting — 1 symbol in 1 file
Test signal per changed area — 2 ✓ · 1 ✗Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
8 test suites also reference this code8 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
…s own footer savingsLine() wrote the `[graft] tokens saved ≈ N` footer with the machine's locale; sumSavingsFooters() read it back with `([\d,]+)`. The two are fifteen lines apart, under a comment saying they are kept together so they cannot drift — and they had not drifted from each other, only from Intl. On a machine that groups thousands with anything but a comma, graft parsed its own footer as the first group: de-DE and tr-TR write `1.990` and get 1, fr-FR and ru-RU write `1 990` (narrow no-break space) and get 1. Everything downstream of savedTokens carried that number, not the real one — the statusline's tokens saved and its dollar value, `graft stats`, and the `saved_tokens_bucket` on session_summary telemetry, all short by ~1000x for every user outside a comma locale. One pinned formatter, used by all five sites that render one of these numbers: the three that write the parsed footer (context/savings.ts, ask/ask.ts, claude/format.ts) and the two displays beside it. The displays are never parsed, but a statusline reading `1.990` above a footer reading `1,990` would be its own small bug, and cli-epilogue.ts already pins the same way. The reader is untouched: widening it would mean guessing, since `1.990` is a valid en-US decimal. The test moves the *default* locale rather than the ambient one — which a process cannot set for itself — so it fails without this fix on an en-US runner too. That is the property CI was missing: its runners always had the comma, so the round trip could never break there. Fixes trailhq#338 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c0a2fb0 to
ba7e3b4
Compare
…parsed line groupDigits() went into three places that write the `[graft] tokens saved ≈ N` line sumSavingsFooters() parses, but the tests only rendered two of them. The third, askSavingsLine() in ask/ask.ts, has its own wording of the footer and no test reached it at all — so reverting it to toLocaleString() would have brought trailhq#338 back for every `graft ask` while the rest of the suite stayed green, which is the kind of drift between writers trailhq#338 was. Renders it through formatAsk() under the same moved default locale as the other two tests. Fails with ask.ts reverted; passes as it is. Refs trailhq#338 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a regression test that walks the TypeScript AST for every file under `src/` and fails if any native `tree-sitter-*` grammar is imported statically. This protects the lazy-loading behavior that prevents missing native prebuilds from crashing the whole CLI, and includes fixtures to prove the scanner catches banned imports without flagging `tree-sitter` itself or lazy `require()` usage. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: sonnet5 <24634891+sonnet5@users.noreply.github.com> Co-Authored-By: opus5 <103333277+opus5@users.noreply.github.com>
Fixes #338.
savingsLine()wrote the footer with the machine's locale;sumSavingsFooters()read it back with([\d,]+). On a machine that groups thousands with anything but a comma, graft parsed its own footer as the first group —1.990came back as1, and every savings total downstream went with it.The change
One formatter, pinned, next to the reader that has to cope with its output:
Five call sites now go through it. Three of them write the
[graft] tokens saved ≈ Nline thatsumSavingsFootersparses —context/savings.ts,ask/ask.ts,claude/format.ts, each with its own wording of the same footer — and the other two are the displays that sit beside it, the statusline's~N tok savedandgraft stats.I pinned the two display sites as well, and that is the one judgement call in here worth flagging. They are never parsed, so locale-aware would be defensible — but graft's output is English throughout, a statusline reading
1.990directly above a footer reading1,990would be its own small bug, andsrc/cli-epilogue.ts:50already pins the same way (nodes.toLocaleString("en-US")), which is where I took the precedent from. I left that line alone rather than reach across from an init banner into the savings module; say the word if you would rather have it routed through the same helper, or would rather keep the two displays locale-aware and pin only the parsed line.The reader is untouched. Widening it instead was the option I would not take:
1.990is a valid en-US decimal, so a reader that strips separators is guessing, and it would have to know about U+202F and U+00A0 too.Tests
test/savings-locale.test.ts. A process cannot change its own ambient locale — Node reads it from the environment on posix and from the OS on Windows — so the test moves the default instead:toLocaleString()with no argument answers as a.-grouping locale would, while calls that name a locale explicitly are left alone. That is exactly the difference the fix turns on, so the test fails without it and passes with it on an en-US runner too, which is the property CI was missing. It asserts the stand-in actually changes an unqualified call first, so the rest cannot pass for the wrong reason.The third test renders the footer through
formatAsk().ask/ask.tshas its own wording of the parsed line, and no test rendered it at all — your blast-radius bot flagged it ("no test reaches Ask Token Savings"), and it was right: reverting just that file totoLocaleString()brought #338 back for everygraft askwith the rest of the suite still green. Now it fails.All three fail on
upstream/main. The first two:Verified
The eight tests that fail on
upstream/mainon this machine — the ones I mentioned in #337 — are the eight this fixes.npm teston atr-TRbox:upstream/main(5 skipped either way.)
Seven of the eight were assertions that assumed
,, and they now hold because the output does. The eighth,tool-savings counts a REAL savings line, was the round trip itself failing1 !== 1990— it is the test that should have caught this and could not, because the footer it feeds is rendered in the runner's own locale. It passes here now, which is the end-to-end proof: a realsavingsLine()through the real hook, on a machine that groups with., recording 1990.One test needed the same treatment as the source.
test/savings.test.ts:52built its expected value with(…).toLocaleString(), so once the footer was pinned it asserted1.990against a footer saying1,990— the mirror image of the bug. It now goes throughgroupDigitstoo.Re-measured against
de8456e(0.18.0) after rebasing onto it: same 8, same numbers.No CHANGELOG entry
I dropped the one I had. 0.18.0 is already released, so there is no unreleased section to add to, and
de8456eshows the release commit is what writes them. Happy to add a bullet wherever you want it.Environment: Node v24.16.0, Windows 11 x64, OS locale
tr-TR.🤖 Generated with Claude Code