From bc71185406ca396b20fd5eee47d16a33d1e0f1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:42:47 +0000 Subject: [PATCH 1/5] Fix UTF-8 code points split across Buffer chunk boundaries Both stream-consuming paths decoded each Buffer chunk independently (chunk.toString('utf8')), so a multi-byte code point straddling a chunk boundary decoded to U+FFFD on both sides. Route Buffer chunks through a persistent StringDecoder: Reader holds one on the instance (flushed via decoder.end() in _finalize so a genuinely truncated trailing sequence still surfaces), and read() uses one across its data events. String chunks bypass the decoder as before. Existing fixtures never caught this because the conformance corpora are pure ASCII. Add a boundary matrix splitting 2-, 3-, and 4-byte code points at every byte position through both Reader and read(), plus a combined case with one boundary inside an escape sequence and another inside a code point. https://claude.ai/code/session_01Mr6xn5zNx83zs5Nmknz5ad --- index.js | 25 +++++++++++---- test-streaming.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 38e7f4e..42976fa 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,8 @@ * - Backslash escapes: \\ for \, \n for newline, \ for empty cell */ +const { StringDecoder } = require('string_decoder'); + /** * Unescape NSV-encoded string * @param {string} str - The escaped string @@ -139,14 +141,16 @@ async function read(input) { // Handle stream const chunks = []; + const decoder = new StringDecoder('utf8'); return new Promise((resolve, reject) => { - input.on('data', chunk => chunks.push(chunk)); + // Buffer chunks go through a persistent StringDecoder: a multi-byte + // code point split across chunk boundaries must not decode to U+FFFD. + input.on('data', chunk => { + chunks.push(typeof chunk === 'string' ? chunk : decoder.write(chunk)); + }); input.on('end', () => { - // Handle both Buffer and string chunks - const text = chunks.map(chunk => - typeof chunk === 'string' ? chunk : chunk.toString('utf8') - ).join(''); + const text = chunks.join('') + decoder.end(); try { resolve(parse(text)); } catch (error) { @@ -239,6 +243,7 @@ class Reader { this._done = false; this._started = false; this._error = null; + this._decoder = new StringDecoder('utf8'); } /** @@ -259,7 +264,9 @@ class Reader { // Otherwise set up stream handlers this.input.on('data', (chunk) => { try { - const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + // The persistent decoder holds back a trailing partial UTF-8 + // sequence until the next chunk completes it. + const text = typeof chunk === 'string' ? chunk : this._decoder.write(chunk); this._processChunk(text); } catch (error) { this._error = error; @@ -305,6 +312,12 @@ class Reader { * @private */ _finalize() { + // Flush a genuinely truncated UTF-8 sequence at stream end + const tail = this._decoder.end(); + if (tail.length > 0) { + this._processChunk(tail); + } + // Handle any remaining buffered content if (this._buffer.length > 0) { this._currentRow.push(unescape(this._buffer)); diff --git a/test-streaming.js b/test-streaming.js index 55be08c..f554360 100644 --- a/test-streaming.js +++ b/test-streaming.js @@ -160,12 +160,94 @@ async function testEmptyRows() { console.log('✓ Empty rows handled correctly\n'); } +// Test 5: Multi-byte UTF-8 code points split across Buffer chunk boundaries. +// The conformance corpora never catch this: they are pure ASCII, so no code +// point can straddle a chunk boundary there. Buffer chunks must go through a +// persistent decoder or a split code point decodes to U+FFFD on both sides. +async function testUtf8ChunkBoundaries() { + console.log('Test 5: UTF-8 code points split across Buffer chunk boundaries'); + + function bufferStream(chunks) { + let index = 0; + return new Readable({ + read() { + this.push(index < chunks.length ? chunks[index++] : null); + } + }); + } + + // 2-, 3-, and 4-byte code points + const cases = [ + { name: '2-byte (é)', text: 'café\n\né\n\n' }, + { name: '3-byte (★)', text: 'a★b\n\n★\n\n' }, + { name: '4-byte (🎉)', text: 'a\u{1f389}b\n\n\u{1f389}\n\n' }, + ]; + + for (const { name, text } of cases) { + const bytes = Buffer.from(text, 'utf8'); + const expected = nsv.parse(text); + + // Split at every byte position, so every code point gets cut at every + // interior byte at some point + for (let split = 0; split <= bytes.length; split++) { + const chunks = [bytes.slice(0, split), bytes.slice(split)]; + + const readerRows = []; + for await (const row of new nsv.Reader(bufferStream(chunks))) { + readerRows.push(row); + } + const readRows = await nsv.read(bufferStream(chunks)); + + for (const [path, rows] of [['Reader', readerRows], ['read()', readRows]]) { + if (JSON.stringify(rows) !== JSON.stringify(expected)) { + console.error(`✗ ${name} via ${path}, split at byte ${split}`); + console.error(' Expected:', expected); + console.error(' Got:', rows); + process.exit(1); + } + } + } + console.log(` ✓ ${name}: all ${bytes.length + 1} split positions, Reader and read()`); + } + + // Combined: one boundary inside an escape sequence, a later one inside a + // code point ('a\nb' encodes as 'a\\nb'; the 🎉 is cut mid-sequence) + { + const text = 'a\\nb\n\né\u{1f389}\n\n'; + const bytes = Buffer.from(text, 'utf8'); + const expected = nsv.parse(text); + const escapeSplit = 2; // between '\\' and 'n' + const codePointSplit = bytes.length - 4; // inside the 4-byte 🎉 + const chunks = [ + bytes.slice(0, escapeSplit), + bytes.slice(escapeSplit, codePointSplit), + bytes.slice(codePointSplit), + ]; + const readerRows = []; + for await (const row of new nsv.Reader(bufferStream(chunks))) { + readerRows.push(row); + } + const readRows = await nsv.read(bufferStream(chunks)); + for (const [path, rows] of [['Reader', readerRows], ['read()', readRows]]) { + if (JSON.stringify(rows) !== JSON.stringify(expected)) { + console.error(`✗ combined escape+code-point split via ${path}`); + console.error(' Expected:', expected); + console.error(' Got:', rows); + process.exit(1); + } + } + console.log(' ✓ combined: boundary in escape sequence + boundary in code point'); + } + console.log('✓ UTF-8 chunk boundaries handled correctly\n'); +} + // Run all tests (async () => { await testChunkedReading(); await testIncrementalWriting(); await testInfiniteStream(); await testEmptyRows(); + await testUtf8ChunkBoundaries(); console.log('✓ All streaming tests passed!'); })().catch(error => { console.error('✗ Test failed:', error); From 2cd7db73f4ae7918b930880feecdab3dba44356e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:42:47 +0000 Subject: [PATCH 2/5] Bump stale cross-test version labels and pins cross-test-rust.js defaulted to nsv 0.0.9 and cross-test-python.js's banner claimed PyPI 0.2.2; current releases are 0.0.12 and 0.2.3 (verified against both registries, and both cross-tests pass against them). Update the CI pins to match so the labels stay truthful. https://claude.ai/code/session_01Mr6xn5zNx83zs5Nmknz5ad --- .github/workflows/ci.yml | 10 +++++----- cross-test-python.js | 2 +- cross-test-rust.js | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ebcb9..2b6763e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: npm test cross-test-python: - name: Cross-test vs Python (PyPI 0.2.2) + name: Cross-test vs Python (PyPI 0.2.3) runs-on: ubuntu-latest steps: @@ -41,18 +41,18 @@ jobs: with: python-version: '3.11' - - name: Install Python NSV 0.2.2 - run: pip install nsv==0.2.2 + - name: Install Python NSV 0.2.3 + run: pip install nsv==0.2.3 - name: Run cross-tests against Python run: node cross-test-python.js cross-test-rust: - name: Cross-test vs Rust (crates.io 0.0.9) + name: Cross-test vs Rust (crates.io 0.0.12) runs-on: ubuntu-latest env: - NSV_RUST_VERSION: "0.0.9" + NSV_RUST_VERSION: "0.0.12" steps: - uses: actions/checkout@v4 diff --git a/cross-test-python.js b/cross-test-python.js index f9d13fd..5d7319b 100644 --- a/cross-test-python.js +++ b/cross-test-python.js @@ -20,7 +20,7 @@ const tests = [ { name: 'complex example', input: 'first\nrow\n\nsecond\nrow\n\nmissing ->\n\\\n<- missing\n\nRoses are red\\nViolets are blue\\nThis may be pain\\nBut CSV would be, too\nTab\\tseparated\\tvalues\\n(would be left as-is normally)\nNot a newline: \\\\n\n' }, ]; -console.log('Cross-testing JS implementation against Python (PyPI 0.2.2)\n'); +console.log('Cross-testing JS implementation against Python (PyPI 0.2.3)\n'); console.log('='.repeat(60) + '\n'); let passCount = 0; diff --git a/cross-test-rust.js b/cross-test-rust.js index dd106a7..babf4ae 100644 --- a/cross-test-rust.js +++ b/cross-test-rust.js @@ -3,8 +3,8 @@ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -// Use NSV_RUST_VERSION env var if set, otherwise default to 0.0.9 -const nsvVersion = process.env.NSV_RUST_VERSION || '0.0.9'; +// Use NSV_RUST_VERSION env var if set, otherwise default to 0.0.12 +const nsvVersion = process.env.NSV_RUST_VERSION || '0.0.12'; // Test cases const tests = [ From 62b92256a64004d67ec5a2f5d5bdccd6c7bb6576 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:55:41 +0000 Subject: [PATCH 3/5] Rework Buffer handling: treat bytes as bytes, drop the UTF-8 assumption The StringDecoder fix made the utf8 assumption correct at chunk boundaries, but baked a text encoding into a format that is deliberately encoding-agnostic. NSV structure is byte-level (only 0x0A, 0x5C, 0x6E are significant), so mirror the Rust implementation's byte-level core (decode_bytes, the bytes-only streaming Reader): assume no encoding and transport Buffer chunks byte-identically via latin1, which is safe at any chunk boundary by construction. String chunks are parsed as given; decoding text is the caller's job (stream.setEncoding, or re-encode cells from latin1). Tests now assert the byte contract: output independent of chunk boundaries (2-, 3-, 4-byte UTF-8 code points split at every byte position, through both Reader and read()), byte-faithful recovery of the original cells, and a combined escape-plus-code-point split. https://claude.ai/code/session_01Mr6xn5zNx83zs5Nmknz5ad --- index.js | 36 +++++++++++++------------ test-streaming.js | 68 ++++++++++++++++++++++++++++------------------- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/index.js b/index.js index 42976fa..4a9073d 100644 --- a/index.js +++ b/index.js @@ -5,10 +5,18 @@ * - Single newlines separate cells within a row * - Double newlines separate rows * - Backslash escapes: \\ for \, \n for newline, \ for empty cell + * + * Encoding: NSV structure is byte-level — only 0x0A, 0x5C, 0x6E are + * significant — with no encoding assumption: the format works over any + * ASCII-compatible encoding. As in the other implementations (cf. Rust's + * decode_bytes / byte-level streaming Reader), this library never decodes + * for you: string input is parsed as given, and Buffer chunks are + * transported as raw bytes (latin1, the byte-identity decoding). Callers + * wanting decoded text should decode themselves (e.g. + * stream.setEncoding('utf8')) or re-encode cells with + * Buffer.from(cell, 'latin1') and decode as they see fit. */ -const { StringDecoder } = require('string_decoder'); - /** * Unescape NSV-encoded string * @param {string} str - The escaped string @@ -141,16 +149,16 @@ async function read(input) { // Handle stream const chunks = []; - const decoder = new StringDecoder('utf8'); return new Promise((resolve, reject) => { - // Buffer chunks go through a persistent StringDecoder: a multi-byte - // code point split across chunk boundaries must not decode to U+FFFD. + // Buffer chunks are bytes, not text: latin1 is the byte-identity + // decoding, so this is safe at any chunk boundary and assumes no + // encoding (see module header). input.on('data', chunk => { - chunks.push(typeof chunk === 'string' ? chunk : decoder.write(chunk)); + chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('latin1')); }); input.on('end', () => { - const text = chunks.join('') + decoder.end(); + const text = chunks.join(''); try { resolve(parse(text)); } catch (error) { @@ -243,7 +251,6 @@ class Reader { this._done = false; this._started = false; this._error = null; - this._decoder = new StringDecoder('utf8'); } /** @@ -264,9 +271,10 @@ class Reader { // Otherwise set up stream handlers this.input.on('data', (chunk) => { try { - // The persistent decoder holds back a trailing partial UTF-8 - // sequence until the next chunk completes it. - const text = typeof chunk === 'string' ? chunk : this._decoder.write(chunk); + // Buffer chunks are bytes, not text: latin1 is the byte-identity + // decoding, so this is safe at any chunk boundary and assumes no + // encoding (see module header). + const text = typeof chunk === 'string' ? chunk : chunk.toString('latin1'); this._processChunk(text); } catch (error) { this._error = error; @@ -312,12 +320,6 @@ class Reader { * @private */ _finalize() { - // Flush a genuinely truncated UTF-8 sequence at stream end - const tail = this._decoder.end(); - if (tail.length > 0) { - this._processChunk(tail); - } - // Handle any remaining buffered content if (this._buffer.length > 0) { this._currentRow.push(unescape(this._buffer)); diff --git a/test-streaming.js b/test-streaming.js index f554360..8f7d714 100644 --- a/test-streaming.js +++ b/test-streaming.js @@ -160,12 +160,15 @@ async function testEmptyRows() { console.log('✓ Empty rows handled correctly\n'); } -// Test 5: Multi-byte UTF-8 code points split across Buffer chunk boundaries. -// The conformance corpora never catch this: they are pure ASCII, so no code -// point can straddle a chunk boundary there. Buffer chunks must go through a -// persistent decoder or a split code point decodes to U+FFFD on both sides. -async function testUtf8ChunkBoundaries() { - console.log('Test 5: UTF-8 code points split across Buffer chunk boundaries'); +// Test 5: Buffer chunks are bytes, not text. The library assumes no encoding: +// Buffer input is transported byte-identically (latin1), so the result must be +// independent of where chunk boundaries fall — including mid-code-point for +// multi-byte UTF-8 payloads — and re-encoding cells with +// Buffer.from(cell, 'latin1') must recover the original bytes exactly. +// The conformance corpora can't probe this: they are pure ASCII, where any +// chunking of any per-chunk decoding is safe. +async function testBufferChunksAreBytes() { + console.log('Test 5: Buffer chunks treated as bytes, invariant under chunk boundaries'); function bufferStream(chunks) { let index = 0; @@ -176,7 +179,16 @@ async function testUtf8ChunkBoundaries() { }); } - // 2-, 3-, and 4-byte code points + async function readBoth(chunks) { + const readerRows = []; + for await (const row of new nsv.Reader(bufferStream(chunks))) { + readerRows.push(row); + } + const readRows = await nsv.read(bufferStream(chunks)); + return [['Reader', readerRows], ['read()', readRows]]; + } + + // UTF-8 payloads with 2-, 3-, and 4-byte code points const cases = [ { name: '2-byte (é)', text: 'café\n\né\n\n' }, { name: '3-byte (★)', text: 'a★b\n\n★\n\n' }, @@ -185,20 +197,25 @@ async function testUtf8ChunkBoundaries() { for (const { name, text } of cases) { const bytes = Buffer.from(text, 'utf8'); - const expected = nsv.parse(text); + // bytes-as-bytes reference: parse of the byte-identity string + const expected = nsv.parse(bytes.toString('latin1')); + + // Byte-faithfulness: cells re-encoded as latin1 and decoded as the + // caller's encoding (utf8 here) recover the original text cells + const recovered = expected.map(row => + row.map(cell => Buffer.from(cell, 'latin1').toString('utf8'))); + if (JSON.stringify(recovered) !== JSON.stringify(nsv.parse(text))) { + console.error(`✗ ${name}: latin1 transport is not byte-faithful`); + console.error(' Expected:', nsv.parse(text)); + console.error(' Got:', recovered); + process.exit(1); + } - // Split at every byte position, so every code point gets cut at every - // interior byte at some point + // Chunking invariance: split at every byte position, so every code point + // gets cut at every interior byte at some point for (let split = 0; split <= bytes.length; split++) { const chunks = [bytes.slice(0, split), bytes.slice(split)]; - - const readerRows = []; - for await (const row of new nsv.Reader(bufferStream(chunks))) { - readerRows.push(row); - } - const readRows = await nsv.read(bufferStream(chunks)); - - for (const [path, rows] of [['Reader', readerRows], ['read()', readRows]]) { + for (const [path, rows] of await readBoth(chunks)) { if (JSON.stringify(rows) !== JSON.stringify(expected)) { console.error(`✗ ${name} via ${path}, split at byte ${split}`); console.error(' Expected:', expected); @@ -207,7 +224,7 @@ async function testUtf8ChunkBoundaries() { } } } - console.log(` ✓ ${name}: all ${bytes.length + 1} split positions, Reader and read()`); + console.log(` ✓ ${name}: byte-faithful, all ${bytes.length + 1} split positions, Reader and read()`); } // Combined: one boundary inside an escape sequence, a later one inside a @@ -215,7 +232,7 @@ async function testUtf8ChunkBoundaries() { { const text = 'a\\nb\n\né\u{1f389}\n\n'; const bytes = Buffer.from(text, 'utf8'); - const expected = nsv.parse(text); + const expected = nsv.parse(bytes.toString('latin1')); const escapeSplit = 2; // between '\\' and 'n' const codePointSplit = bytes.length - 4; // inside the 4-byte 🎉 const chunks = [ @@ -223,12 +240,7 @@ async function testUtf8ChunkBoundaries() { bytes.slice(escapeSplit, codePointSplit), bytes.slice(codePointSplit), ]; - const readerRows = []; - for await (const row of new nsv.Reader(bufferStream(chunks))) { - readerRows.push(row); - } - const readRows = await nsv.read(bufferStream(chunks)); - for (const [path, rows] of [['Reader', readerRows], ['read()', readRows]]) { + for (const [path, rows] of await readBoth(chunks)) { if (JSON.stringify(rows) !== JSON.stringify(expected)) { console.error(`✗ combined escape+code-point split via ${path}`); console.error(' Expected:', expected); @@ -238,7 +250,7 @@ async function testUtf8ChunkBoundaries() { } console.log(' ✓ combined: boundary in escape sequence + boundary in code point'); } - console.log('✓ UTF-8 chunk boundaries handled correctly\n'); + console.log('✓ Buffer chunks handled as bytes correctly\n'); } // Run all tests @@ -247,7 +259,7 @@ async function testUtf8ChunkBoundaries() { await testIncrementalWriting(); await testInfiniteStream(); await testEmptyRows(); - await testUtf8ChunkBoundaries(); + await testBufferChunksAreBytes(); console.log('✓ All streaming tests passed!'); })().catch(error => { console.error('✗ Test failed:', error); From fe3d774c646eeb0d3c8000840e24f31c5af78159 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:23:06 +0000 Subject: [PATCH 4/5] Drop explanatory comments https://claude.ai/code/session_01Mr6xn5zNx83zs5Nmknz5ad --- index.js | 17 +---------------- test-streaming.js | 20 +++----------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/index.js b/index.js index 4a9073d..23e65ec 100644 --- a/index.js +++ b/index.js @@ -5,16 +5,6 @@ * - Single newlines separate cells within a row * - Double newlines separate rows * - Backslash escapes: \\ for \, \n for newline, \ for empty cell - * - * Encoding: NSV structure is byte-level — only 0x0A, 0x5C, 0x6E are - * significant — with no encoding assumption: the format works over any - * ASCII-compatible encoding. As in the other implementations (cf. Rust's - * decode_bytes / byte-level streaming Reader), this library never decodes - * for you: string input is parsed as given, and Buffer chunks are - * transported as raw bytes (latin1, the byte-identity decoding). Callers - * wanting decoded text should decode themselves (e.g. - * stream.setEncoding('utf8')) or re-encode cells with - * Buffer.from(cell, 'latin1') and decode as they see fit. */ /** @@ -151,9 +141,7 @@ async function read(input) { const chunks = []; return new Promise((resolve, reject) => { - // Buffer chunks are bytes, not text: latin1 is the byte-identity - // decoding, so this is safe at any chunk boundary and assumes no - // encoding (see module header). + // Handle both Buffer and string chunks input.on('data', chunk => { chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('latin1')); }); @@ -271,9 +259,6 @@ class Reader { // Otherwise set up stream handlers this.input.on('data', (chunk) => { try { - // Buffer chunks are bytes, not text: latin1 is the byte-identity - // decoding, so this is safe at any chunk boundary and assumes no - // encoding (see module header). const text = typeof chunk === 'string' ? chunk : chunk.toString('latin1'); this._processChunk(text); } catch (error) { diff --git a/test-streaming.js b/test-streaming.js index 8f7d714..310940f 100644 --- a/test-streaming.js +++ b/test-streaming.js @@ -160,13 +160,7 @@ async function testEmptyRows() { console.log('✓ Empty rows handled correctly\n'); } -// Test 5: Buffer chunks are bytes, not text. The library assumes no encoding: -// Buffer input is transported byte-identically (latin1), so the result must be -// independent of where chunk boundaries fall — including mid-code-point for -// multi-byte UTF-8 payloads — and re-encoding cells with -// Buffer.from(cell, 'latin1') must recover the original bytes exactly. -// The conformance corpora can't probe this: they are pure ASCII, where any -// chunking of any per-chunk decoding is safe. +// Test 5: Buffer chunks are bytes — result independent of chunk boundaries async function testBufferChunksAreBytes() { console.log('Test 5: Buffer chunks treated as bytes, invariant under chunk boundaries'); @@ -188,7 +182,6 @@ async function testBufferChunksAreBytes() { return [['Reader', readerRows], ['read()', readRows]]; } - // UTF-8 payloads with 2-, 3-, and 4-byte code points const cases = [ { name: '2-byte (é)', text: 'café\n\né\n\n' }, { name: '3-byte (★)', text: 'a★b\n\n★\n\n' }, @@ -197,11 +190,8 @@ async function testBufferChunksAreBytes() { for (const { name, text } of cases) { const bytes = Buffer.from(text, 'utf8'); - // bytes-as-bytes reference: parse of the byte-identity string const expected = nsv.parse(bytes.toString('latin1')); - // Byte-faithfulness: cells re-encoded as latin1 and decoded as the - // caller's encoding (utf8 here) recover the original text cells const recovered = expected.map(row => row.map(cell => Buffer.from(cell, 'latin1').toString('utf8'))); if (JSON.stringify(recovered) !== JSON.stringify(nsv.parse(text))) { @@ -211,8 +201,6 @@ async function testBufferChunksAreBytes() { process.exit(1); } - // Chunking invariance: split at every byte position, so every code point - // gets cut at every interior byte at some point for (let split = 0; split <= bytes.length; split++) { const chunks = [bytes.slice(0, split), bytes.slice(split)]; for (const [path, rows] of await readBoth(chunks)) { @@ -227,14 +215,12 @@ async function testBufferChunksAreBytes() { console.log(` ✓ ${name}: byte-faithful, all ${bytes.length + 1} split positions, Reader and read()`); } - // Combined: one boundary inside an escape sequence, a later one inside a - // code point ('a\nb' encodes as 'a\\nb'; the 🎉 is cut mid-sequence) { const text = 'a\\nb\n\né\u{1f389}\n\n'; const bytes = Buffer.from(text, 'utf8'); const expected = nsv.parse(bytes.toString('latin1')); - const escapeSplit = 2; // between '\\' and 'n' - const codePointSplit = bytes.length - 4; // inside the 4-byte 🎉 + const escapeSplit = 2; + const codePointSplit = bytes.length - 4; const chunks = [ bytes.slice(0, escapeSplit), bytes.slice(escapeSplit, codePointSplit), From cea807b733294b971d4a3d75d0be14a5a0ede836 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:32:36 +0000 Subject: [PATCH 5/5] Restore read() chunk handling shape https://claude.ai/code/session_01Mr6xn5zNx83zs5Nmknz5ad --- index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 23e65ec..5986184 100644 --- a/index.js +++ b/index.js @@ -141,12 +141,12 @@ async function read(input) { const chunks = []; return new Promise((resolve, reject) => { - // Handle both Buffer and string chunks - input.on('data', chunk => { - chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('latin1')); - }); + input.on('data', chunk => chunks.push(chunk)); input.on('end', () => { - const text = chunks.join(''); + // Handle both Buffer and string chunks + const text = chunks.map(chunk => + typeof chunk === 'string' ? chunk : chunk.toString('latin1') + ).join(''); try { resolve(parse(text)); } catch (error) {