Give fjs/text/ascii the hex-digit codec its three consumers were duplicating - #1522
Conversation
The hex-digit ↔ value mapping — the `'0'`, `'a' - 10` and `'A' - 10` offsets — was written three times: encode-side in the JSON serializer, decode-side as a range-map dispatch in `js/tokenizer`, and decode-side as a ternary chain in `djs/tokenizer`, which built its own `AF` range and assumed lowercase in the fallthrough (a non-hex code point decoded to a garbage digit). `fjs/text/ascii` already owns the constants, so it now owns the codec too: `hexDigitValue` (`Nullable`, `null` for a non-hex code point), `hexDigitCodePoint` (lowercase), plus the `a-f` / `A-F` ranges. All three consumers use it. `js/tokenizer`'s `unicodeChar` state no longer needs its three-range dispatch — a `null` value is exactly the input the default handler rejects — and `djs/tokenizer` wraps the codec with `mapUnwrap`, so the grammar-guaranteed invariant asserts instead of decoding garbage. Closes the `fjs/text/ascii/todo/hex-digit-codec.md` issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CWo5y1CqQ8fgVSn3XHEM8W
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CWo5y1CqQ8fgVSn3XHEM8W
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 1fdbed3 | Commit Preview URL Branch Preview URL |
Aug 13 2026, 11:02 AM |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving. This is a clean extraction, and the DJS fix that came with it is the real prize.
Gates
npx tsc --noEmit→ 0npm test→ 2530 pass / 0 fail vs 2524 onorigin/main; the delta is exactly the 6 new proof cases, no test lostnpm run prepackfrom a clean tree → 0- Public surface diff (
extract.mjs46 KB,consts.mjs65 KB both sides, so the tools produced real output): zero type-alias changes; consts gain exactly four entries, all infjs/text/ascii—latinCapitalLetterAFRange: Range,latinSmallLetterAFRange: Range,hexDigitValue: (codePoint: number) => Nullable<number>,hexDigitCodePoint: (value: number) => number. No unprefixedexport typeadded, nothing widened toany. - Broken-link sets identical to main (140 both, empty set diff); no dangling reference to the deleted
todo/hex-digit-codec.mdanywhere in the tree. @modulesurvives declaration emit infjs/text/ascii/module.f.d.mts(§4 blank line intact) even with the new@importtags after it.- The new
import { contains } from '../../types/range/module.f.mjs'introduces no cycle —fjs/types/range/module.f.mjshas no imports at all — and every const consumed at module-eval time (digitRange,latinSmallLetterA,latinCapitalLetterA) is defined above its use.
Behavioural equivalence at the three call sites
I compared old against new rather than trusting the extraction, exhaustively over code points 0..0x2000:
fjs/js/tokenizer— the oldcreate(parseUnicodeCharDefault)([rangeFunc(digitRange)…, rangeFunc(rangeSmallAF)…, rangeFunc(rangeCapitalAF)…])dispatch vs the newhexDigitValue(input) === null ? default : …: 0 mismatches, treatingnullas "fell through to the default handler". The'invalid hex value'path is still exercised byfjs/js/tokenizer/proof.f.mjs:163.fjs/media/json/serializer—value < 10 ? digit0 + value : latinSmallLetterA + value - 10vshexDigitCodePoint: 0 mismatches over0..15, and they also agree outside the documented domain (16→0x67in both), so nothing changes even for a caller that was out of contract.fjs/djs/tokenizer— not equivalent, deliberately, and this is the good part. The old chain's finalelsewas unguarded: any non-hex code point reaching theunicodestate decoded tocp - 87, socp = 0produced-87and the accumulator absorbed it silently.mapUnwrap(hexDigitValue)now asserts instead. I checked the unreachability claim in the header comment rather than taking it: the token grammar at:74-92spells the escape as'u', ...repeat(4)({ digit, AF: range('AF'), af: range('af') }), so the four hex digits are grammar-accepted beforedecodeJsonStringruns. The assert is genuinely defensive, and the comment says exactly that.
Boundary spot-check on the new function: '0'→0, '9'→9, 'a'→10, 'f'→15, 'A'→10, 'F'→15, and '/' ':' '@' 'G' '`' 'g' → null.
Proof (§3.2)
Mutation-tested the new proof, eight mutations, all killed:
| mutation | killed by |
|---|---|
codePoint - digit0 → + 1 |
hexDigitValue.digit, hexDigitCodePoint.roundTrip |
| lowercase offset off-by-one | hexDigitValue.latinSmallLetterAF, roundTrip |
| uppercase offset off-by-one | hexDigitValue.latinCapitalLetterAF |
: null → : 0 |
hexDigitValue.notAHexDigit |
value < 10 → value <= 10 |
hexDigitCodePoint.lowercaseDigits, roundTrip |
range('af') → range('ae') |
hexDigitValue.latinSmallLetterAF, roundTrip |
range('AF') → range('AE') |
hexDigitValue.latinCapitalLetterAF |
isDigit(codePoint) ? → false ? |
hexDigitValue.digit, roundTrip |
One nit, not a blocker
notAHexDigit pins five of the six boundary neighbours — '/' (0x2F), ':' (0x3A), '@' (0x40), 'G' (0x47), 'g' (0x67) — but not '`' (0x60), the code point just below 'a'. That is the one mutation that survives: widening latinSmallLetterAFRange to [0x60, 0x66] leaves all 8 cases green. Behaviour is correct today; it is only the lower bound of the lowercase range that is unpinned, and one more assertEq(hexDigitValue(one('\')), null)` closes it.
CHANGELOG entry is well-formed — code change, links only /pull/1522, and it names the DJS behaviour change explicitly rather than burying it.
`notAHexDigit` covered five of the six boundary neighbours; the missing one (0x60, just below 'a') left `latinSmallLetterAFRange`'s lower bound unpinned, so widening it to [0x60, 0x66] kept every case green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CWo5y1CqQ8fgVSn3XHEM8W
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-approving at 1fdbed37 — the head moved right after my previous review, adding exactly the missing boundary case.
Re-verified at the new head:
npx tsc --noEmit→ 0;npm test→ 2530 pass / 0 fail, unchanged- The mutation that survived before now dies: widening
latinSmallLetterAFRangeto[0x60, 0x66]failshexDigitValue.notAHexDigit, where previously all 8 cases stayed green
Everything else in my review at 04d36a89 stands — the three call sites are unchanged by this commit, so the equivalence measurements and the surface diff carry over.
Implements
fjs/text/ascii/todo/hex-digit-codec.md(P3, open, no blockers), deleted here.Problem
One mapping — hex-digit code point ↔ value, i.e. the offsets
digit0,latinSmallLetterA - 10,latinCapitalLetterA - 10— was written three times, in both directions:fjs/media/json/serializervalue < 10 ? digit0 + value : latinSmallLetterA + value - 10fjs/js/tokenizerparseUnicodeCharHex(offset)per rangefjs/djs/tokenizerAFrangeThe
af/AFranges were built independently in the two tokenizers as well. The djs copy's fallthrough assumed lowercase without checking, so a non-hex code point decoded to a garbage digit where the js copy had a real reject path.Change
fjs/text/asciialready ownsdigit0,latinSmallLetterA,latinCapitalLetterA(andlatinSmallLetterF/latinCapitalLetterF, which exist for no other reason), so it now owns the codec:hexDigitValue: (codePoint: number) => Nullable<number>— value0..15,nullfor anything that is not0-9/a-f/A-F;hexDigitCodePoint: (value: number) => number— the lowercase digit for a value in0..15;latinSmallLetterAFRange/latinCapitalLetterAFRangebeside the existingdigitRange.Both offsets are computed once, and
hexDigitCodePointis written in terms of the samelatinSmallLetterAFOffsetthathexDigitValueinverts.All three consumers now call it:
hexDigitisfromCharCode(hexDigitCodePoint(value)).js/tokenizer— theunicodeCharstate drops its three-range dispatch entirely.hexDigitValueclassifies and decodes in one step, andnullis exactly the non-hex inputparseUnicodeCharDefaultalready rejected with'invalid hex value', so both the accept and reject paths stay covered by the existing proofs.djs/tokenizer—mapUnwrap(hexDigitValue), following thetry*+mapUnwrapprecedent infjs/textandfjs/types/bit_vec. A\uXXXXescape reaches that state only after the grammar accepted its four hex digits, so the impossible case now asserts rather than decoding to garbage — and the assert branch lives in the shared helper, adding no uncovered branch at the call site.No public export is removed; everything deleted was module-private, so this is additive.
Checks
npx tsc— clean.npm start test— 2530 pass, 0 fail.npm run cov—fjs/text/ascii/module.f.mjsat 100% line/branch/function; no new uncovered line, branch, or function anywhere. The two tokenizers' percentages move by hundredths purely because covered branches/functions were removed (djs96.89 → 96.83branch, js97.53 → 97.54branch /97.42 → 97.40function).npm run ci-update— no diff.Rust untouched.
Generated by Claude Code