fix: convert WASM byte offsets to code-unit offsets before slicing - #68
fix: convert WASM byte offsets to code-unit offsets before slicing#68technoligest wants to merge 1 commit into
Conversation
split_offsets returns UTF-8 byte offsets, but String.prototype.slice
indexes UTF-16 code units. The two coincide only for ASCII, so any
non-ASCII character desynchronizes them:
split_offsets('Γ© cd', { delimiters: ' ', includeDelim: 'none' })
// [[0,2],[3,5]] -> text.slice() yields ["Γ© ", "d"], losing the 'c'
RecursiveChunker, SentenceChunker and SemanticChunker all applied these
offsets directly to strings. Since RecursiveChunker rejoins whitespace
splits with a space, the damage was length-preserving and therefore
silent. An astral character drifts the boundary by 2 bytes, which can
land inside a surrogate pair and emit a lone surrogate - unrepresentable
in UTF-8, so downstream JSON APIs reject the chunk outright.
FastChunker was already correct: it slices the byte array and decodes,
rather than mixing coordinate systems.
Adds toCharOffsets, which walks the string once to map byte offsets to
code-unit offsets and returns ASCII input untouched.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
π WalkthroughWalkthroughAdds UTF-8 byte to UTF-16 offset conversion and applies it to recursive, semantic, and sentence chunkers. New tests cover ASCII, accented characters, punctuation, emoji, surrogate pairs, chunk boundaries, and source slicing. ChangesUnicode offset conversion
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Chunker
participant WASM_split_offsets
participant toCharOffsets
participant JavaScript_slice
Chunker->>WASM_split_offsets: request UTF-8 byte offsets
WASM_split_offsets-->>toCharOffsets: return offset pairs
toCharOffsets-->>Chunker: return UTF-16 code-unit pairs
Chunker->>JavaScript_slice: slice source text
Poem
π₯ Pre-merge checks | β 5β Passed checks (5 passed)
β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/tests/unicode-offsets.test.ts`:
- Line 82: Raise the repository and workspace Node engine lower bounds from 18
to 20, including the relevant declarations in package configuration and
pnpm-workspace.yaml. Update packages/core/tsconfig.json to use a library target
that defines String.prototype.isWellFormed(), then retain the isWellFormed()
test assertion in unicode-offsets.test.ts.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 61bca28c-b952-437c-998b-1269a86c2e2b
π Files selected for processing (5)
packages/core/src/offsets.tspackages/core/src/recursive.tspackages/core/src/semantic.tspackages/core/src/sentence.tspackages/core/tests/unicode-offsets.test.ts
| const chunks = await chunker.chunk(PARITY_CASES.emoji.wide); | ||
|
|
||
| for (const chunk of chunks) { | ||
| expect(chunk.text.isWellFormed()).toBe(true); |
There was a problem hiding this comment.
π©Ί Stability & Availability | π‘ Minor | β‘ Quick win
π§© Analysis chain
π Script executed:
#!/bin/bash
# Check declared TS lib/target and Node engine requirements
fd tsconfig.json | xargs -I{} sh -c 'echo "== {} =="; cat {}'
fd package.json -d 2 | xargs -I{} sh -c 'echo "== {} =="; jq ".engines" {} 2>/dev/null'
fd '.nvmrc|.tool-versions' -HRepository: feyninc/chonkiejs
Length of output: 2641
π Script executed:
#!/bin/bash
set -e
echo "== package.json files with engines/scripts =="
git ls-files '*package.json' | while read -r f; do
echo "== $f =="
jq '{name, type, engines, scripts, dependencies, devDependencies}' "$f"
done
echo
echo "== workflow node-version declarations =="
if [ -d .github/workflows ]; then
fd -a . .github/workflows -t f | while read -r f; do echo "== $f =="; rg -n "node-version|nodejs|setup-node|vitest|pnpm|npm|yarn|bun" "$f" || true; done
else
echo "no .github/workflows directory"
fi
echo
echo "== unicode-offsets test relevant lines =="
fd unicode-offsets.test.ts -t f | while read -r f; do
echo "== $f =="
sed -n '1,180p' "$f"
done
echo
echo "== TypeScript declarations for isWellFormed in built-in libs =="
fd 'lib.es2023.*.d.ts|lib.es2024.*.d.ts|lib.esnext.*.d.ts' /var 2>/dev/null || trueRepository: feyninc/chonkiejs
Length of output: 9926
π Script executed:
#!/bin/bash -
# Inspect packages/core test config inheritance path and any lockfile engine pins.
set -e
echo "== packages/core/package.json =="
jq '{name, type, engines, scripts, dependencies, devDependencies}' packages/core/package.json
echo
echo "== packages/core/tests/tsconfig.json =="
cat packages/core/tests/tsconfig.json
echo
echo "== packages/core/tsconfig.json =="
cat packages/core/tsconfig.json
echo
echo "== package manager files =="
git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true
echo
echo "== lockfile engines/node declarations (first 120 matches) =="
for f in $(git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true); do
echo "== $f =="
rg -n "engines:|node:|node-version|`@chonkiejs/core`|typescript|vitest" "$f" | head -120 || true
done
echo
echo "== TypeScript installed availability =="
node - <<'JS'
try {
const fs = require('fs');
const path = require('path');
for (const root of ['node_modules', process.cwd() + '/node_modules']) {
const file = path.join(root, 'typescript/lib/lib.es2024.string.d.ts');
if (fs.existsSync(file)) {
console.log('types file exists:', file);
const text = fs.readFileSync(file, 'utf8');
console.log(text.includes('isWellFormed') ? 'isWellFormed declaration present' : 'isWellFormed declaration absent');
}
}
console.log('node', process.version);
} catch (e) {
console.log('type check unavailable:', e.message);
}
JSRepository: feyninc/chonkiejs
Length of output: 10387
Raise the test/CI Node lower bound before using String.isWellFormed().
packages/core/tests inherits packages/core/tsconfig.json, which still uses lib: ["ES2021"], so these calls will fail type-checking unless that lib is updated. Add Node 20 to the repo and pnpm-workspace.yaml declarations too; the current workspace/engine config only requires Node 18, but String.prototype.isWellFormed() needs Node 20.
π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/tests/unicode-offsets.test.ts` at line 82, Raise the repository
and workspace Node engine lower bounds from 18 to 20, including the relevant
declarations in package configuration and pnpm-workspace.yaml. Update
packages/core/tsconfig.json to use a library target that defines
String.prototype.isWellFormed(), then retain the isWellFormed() test assertion
in unicode-offsets.test.ts.
The bug
split_offsetsreturns UTF-8 byte offsets β as its own docstring inchunk.d.tssays β butString.prototype.sliceindexes UTF-16 code units. The two coincide only for ASCII, which is why this hasn't surfaced before. Any non-ASCII character desynchronizes them:Γ©andβdrift the boundary by 1 byte, an emoji by 2. Three chunkers apply these offsets straight to a string:recursive.tsβ whitespace level and delimiter levelsentence.tsβ the single-character-delimiter path (the multi-character defaults go throughsplitByPatternsOffsets, which is correct)semantic.tsβ sentence preparationFastChunkeralready does it right β it slices theUint8Arrayand decodes β so the fix here just brings the other three in line.Why it's easy to miss
RecursiveChunkerrejoins whitespace splits with' ', so the corruption is length-preserving: characters are overwritten rather than dropped, and nothing downstream looks wrong. On a real 1246-page document I hit:SentenceChunker's symptom is different β boundaries land mid-word, andendIndexcan run pasttext.length(I sawendIndex69 on a 64-character string).The worst case is astral characters: a drift of 2 can land the boundary inside a surrogate pair. The resulting lone surrogate is unrepresentable in UTF-8, so
JSON.stringifyemits\udXXX, and downstream APIs reject the payload. That's how I found this β a reranking API returned400 β¦ ensure that your input is encoded in valid UTF-8 formaton one chunk in 5,550.The fix
Adds
toCharOffsets(text, byteOffsets), which walks the string once to map UTF-8 byte offsets onto UTF-16 code-unit offsets, and returns ASCII input untouched so the common path stays free. The three chunkers now wrap theirsplit_offsetscalls with it.No changes to the Rust/WASM side β its contract is documented and correct; only the JS consumers were mixing coordinate systems.
Verification
packages/core/tests/unicode-offsets.test.tsadds 19 tests. The 15 integration tests all fail onmainand pass with this change; the 4toCharOffsetsunit tests cover the conversion directly.The chunker tests pair each non-ASCII input with an ASCII input of identical code-unit length (
cafΓ©vscafe,π linkvsab link) and assert both produce identical chunk lengths and offsets β which isolates this bug fromRecursiveChunker's existing whitespace-delimiter behaviour.Against that 1246-page document (3.9 MB, 1,244 page segments, 182 emoji plus pervasive en-dashes), chunked at
chunkSize: 1024:The 65 remaining are not encoding-related: replacing every non-ASCII code unit with an ASCII one of the same length reproduces all 65 exactly, so they're the pre-existing whitespace-delimiter drop at the whitespace level (
includeDelim: 'none'discards the delimiter, and the rejoin restores one space). Encoding-specific corruption goes to 0. I left that separate behaviour alone since it's orthogonal to this fix β happy to look at it separately if it's considered a bug.Full suite: no regressions.
tests/code.test.ts(9) and oneinit-allcase fail identically before and after on my machine β they need an optional tree-sitter language pack.Summary by CodeRabbit
Bug Fixes
Tests