Skip to content

Commit 0291333

Browse files
committed
Added simplified replacement + fast check tests.
1 parent 0c816ae commit 0291333

11 files changed

Lines changed: 375 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ test('example', async t => {
164164
- Test files use `tape-six`: `.js` for runtime tests, `.ts` for typed tests.
165165
- Test file naming convention: `test-*.js` (or `test-types-*.ts`) in `tests/`.
166166
- Tests are configured in `package.json` under the `"tape6"` section.
167+
- Property-based tests live in `tests/node/test-property-parser.js` (`tape-six-fast-check`, `t.prop()` over fast-check arbitraries): chunk-boundary invariance for the JSON and JSONC parsers and the two round-trips. A new streaming invariant belongs there, not in an example-based file.
167168
- Test files should be directly executable: `node tests/test-foo.js`.
168169

169170
## Token protocol

llms-full.txt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,18 +433,17 @@ Replaces matching subobjects with a replacement value. Needs packed keys (`keyVa
433433

434434
Extra option:
435435
- `replacement` — the replacement:
436-
- **function** `(stack, chunk, options) => tokens` — dynamic replacement; return `none` to remove the value.
437-
- **token** — a single static token, such as `{name: 'nullValue', value: null}`.
438-
- **array** — array of tokens to insert.
439-
- Anything else, including `null`, removes the value.
436+
- **any JSON-compatible value** — a number, string, boolean, `null`, array, or plain object — disassembled into tokens once and substituted as that JSON value (shaped by the same packing/streaming options as the parser).
437+
- **token** / **array of tokens** — substituted verbatim; an empty array removes the value (compatibility), an empty JSON array is its two tokens.
438+
- **function** `(stack, chunk, options) => value` — dynamic; the result is interpreted as above; return `none` to remove the value.
440439
- Default: `none` (removes the value, replaced by nothing).
441440

442441
```js
443442
import {replace} from 'stream-json/filters/replace.js';
444443
import {stringer} from 'stream-json/stringer.js';
445444

446445
// Replace 'extra' with null
447-
chain([parser(), replace({filter: /^\d+\.extra\b/, replacement: [{name: 'nullValue', value: null}]}), stringer()]);
446+
chain([parser(), replace({filter: /^\d+\.extra\b/, replacement: null}), stringer()]);
448447

449448
// Replace with custom function
450449
chain([parser(), replace({

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ e.addEventListener('keyValue', ev => console.log(ev.detail));
123123
All filters accept `{filter, pathSeparator, once, maxDepth, streamKeys}` options. Key-based paths and parent recreation need packed keys from upstream (the parser's default). Replayed parent keys are always packed; their streamed form mirrors upstream unless `streamKeys` says otherwise. `packKeys` on a filter is a deprecated no-op. `filter` can be a string, RegExp, or `(stack, chunk) => boolean`. `maxDepth` caps the JSON nesting depth a filter evaluates (default `1024`; a deeper token throws a `RangeError`, `Infinity` disables the limit).
124124

125125
- **`pick(options)`** — passes only matching subobjects, discards the rest.
126-
- **`replace(options)`** — replaces matching subobjects. Extra option: `replacement` (function, token, or array of tokens).
126+
- **`replace(options)`** — replaces matching subobjects. Extra option: `replacement` (any JSON-compatible value — disassembled for you — a token or token array, or a function returning one).
127127
- **`ignore(options)`** — removes matching subobjects completely.
128128
- **`filter(options)`** — keeps matching subobjects preserving surrounding structure.
129129

package-lock.json

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
},
7373
"devDependencies": {
7474
"@types/node": "^26.1.2",
75+
"fast-check": "^4.9.0",
7576
"nano-benchmark": "^1.2.0",
7677
"prettier": "^3.9.6",
7778
"tape-six": "^1.16.2",
79+
"tape-six-fast-check": "^1.0.0",
7880
"tape-six-proc": "^1.3.1",
7981
"typescript": "^7.0.2"
8082
},

src/core/filters/replace.d.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,23 @@ declare namespace replace {
2020
/** Options for `replace`, extending filter base options with a replacement value. */
2121
export interface ReplaceOptions extends filterBase.FilterBaseOptions {
2222
/**
23-
* What to substitute for matched subobjects.
24-
* - **function** — called with `(stack, chunk, options)`; returns tokens, or `none` to remove the value.
25-
* - **Token[]** / **Many<Token>** — a static array of replacement tokens.
26-
* - **Token** — a single static token, such as `{name: 'nullValue', value: null}`.
27-
* - **`null`** — no replacement: the matched value is removed, like `ignore`.
28-
* - Default: none (the matched value is removed).
23+
* What to substitute for matched subobjects:
24+
* - a **function** `(stack, chunk, options)` — called per match; its result is
25+
* interpreted like a static value below (`none` removes the value);
26+
* - a **token**, a **token array**, or a `Many` of tokens — substituted verbatim;
27+
* - **any other value** — a number, string, boolean, `null`, array, or plain
28+
* object — disassembled into tokens once and substituted as that JSON value,
29+
* shaped by the same packing/streaming options as the parser. An empty
30+
* array is an empty token list and removes the value (kept for
31+
* compatibility); an empty JSON array is `[{name: 'startArray'}, {name: 'endArray'}]`.
32+
* - Default (option absent): none — the matched value is removed, like `ignore`.
2933
*/
3034
replacement?:
31-
| ((
32-
stack: (string | number | null)[],
33-
chunk: parser.Token,
34-
options: filterBase.FilterBaseOptions
35-
) => parser.Token | parser.Token[] | Many<parser.Token> | typeof none)
35+
| ((stack: (string | number | null)[], chunk: parser.Token, options: filterBase.FilterBaseOptions) => unknown)
3636
| parser.Token
3737
| parser.Token[]
3838
| Many<parser.Token>
39+
| {}
3940
| null;
4041
}
4142
/** Creates a `parser() + replace()` pipeline as a flushable function. */

src/core/filters/replace.js

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,61 @@
33
import {many, none, combineManyMut, getManyValues, isMany} from 'stream-chain/core';
44

55
import {filterBase, makeStackDiffer} from './filter-base.js';
6+
import {disassembler} from '../disassembler.js';
7+
8+
// an object with one of these names is a token; anything else is a plain value to disassemble
9+
const tokenNames = {
10+
startObject: 1,
11+
endObject: 1,
12+
startArray: 1,
13+
endArray: 1,
14+
startKey: 1,
15+
endKey: 1,
16+
startString: 1,
17+
endString: 1,
18+
startNumber: 1,
19+
endNumber: 1,
20+
stringChunk: 1,
21+
numberChunk: 1,
22+
keyValue: 1,
23+
stringValue: 1,
24+
numberValue: 1,
25+
nullValue: 1,
26+
trueValue: 1,
27+
falseValue: 1,
28+
whitespace: 1,
29+
comma: 1,
30+
startComment: 1,
31+
commentChunk: 1,
32+
endComment: 1,
33+
commentValue: 1
34+
};
35+
const isToken = value => value !== null && typeof value == 'object' && tokenNames[value.name] === 1;
36+
637
const defaultReplacement = () => none;
738

839
const replace = options => {
40+
const toTokens = disassembler(options); // shaped by the same packing/streaming options as the parser
41+
const normalize = value => {
42+
if (value === none) return none;
43+
if (value !== null && typeof value == 'object') {
44+
if (isMany(value)) return value;
45+
if (Array.isArray(value)) {
46+
if (value.every(isToken)) return many(value);
47+
} else if (tokenNames[value.name] === 1) {
48+
return value;
49+
}
50+
}
51+
return many([...toTokens(value)]);
52+
};
953
let replacementValue = options?.replacement;
1054
/** @type {any} */
1155
let replacement = defaultReplacement;
12-
switch (typeof replacementValue) {
13-
case 'function':
14-
replacement = replacementValue;
15-
break;
16-
case 'object':
17-
if (Array.isArray(replacementValue)) replacementValue = many(replacementValue);
18-
if (replacementValue) replacement = () => replacementValue;
19-
break;
56+
if (typeof replacementValue == 'function') {
57+
replacement = (stack, chunk, options) => normalize(replacementValue(stack, chunk, options));
58+
} else if (replacementValue !== undefined) {
59+
replacementValue = normalize(replacementValue);
60+
replacement = () => replacementValue;
2061
}
2162
const stackDiffer = makeStackDiffer();
2263
return filterBase({
@@ -25,7 +66,6 @@ const replace = options => {
2566
transition(stack, chunk, action, options) {
2667
if (action !== 'reject' && action !== 'reject-value') return stackDiffer(stack, chunk, options);
2768
let replacementTokens = replacement(stack, chunk, options);
28-
if (Array.isArray(replacementTokens)) replacementTokens = many(replacementTokens);
2969
if (replacementTokens === none || (isMany(replacementTokens) && !getManyValues(replacementTokens).length)) return none;
3070
return combineManyMut(stackDiffer(stack, null, options), replacementTokens);
3171
}

tests/node/test-property-parser.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Property-based tests (tape-six-fast-check): the streaming invariants that
2+
// example-based tests structurally miss — the same document split at arbitrary
3+
// chunk boundaries must yield the same tokens (GHSA-hqr4-qq8f-hg3x was exactly
4+
// this class), and the parser/assembler and disassembler/stringer pairs must
5+
// round-trip against JSON.parse / JSON.stringify.
6+
import test from 'tape-six';
7+
import fc from 'fast-check';
8+
import 'tape-six-fast-check';
9+
import chain from 'stream-chain';
10+
import {none} from 'stream-chain/core';
11+
import {Readable} from 'node:stream';
12+
13+
import {parser} from '../../src/index.js';
14+
import {parser as jsoncParser} from '../../src/jsonc/parser.js';
15+
import Assembler from '../../src/assembler.js';
16+
import {disassembler} from '../../src/disassembler.js';
17+
import {stringer} from '../../src/stringer.js';
18+
19+
const splitAt = (text, offsets) => {
20+
const cuts = [...new Set(offsets.map(o => o % (text.length + 1)))].sort((a, b) => a - b);
21+
const chunks = [];
22+
let prev = 0;
23+
for (const cut of cuts) {
24+
if (cut > prev) chunks.push(text.slice(prev, cut));
25+
prev = cut;
26+
}
27+
if (prev < text.length) chunks.push(text.slice(prev));
28+
return chunks;
29+
};
30+
31+
const tokensOf = (chunks, factory, options) =>
32+
new Promise((resolve, reject) => {
33+
const tokens = [],
34+
pipeline = chain([Readable.from(chunks), factory(options)]);
35+
pipeline.on('data', token => tokens.push(token));
36+
pipeline.on('error', reject);
37+
pipeline.on('end', () => resolve(tokens));
38+
});
39+
40+
// merge streamed pieces so two splits of the same text compare equal
41+
const coalesce = tokens => {
42+
const out = [];
43+
for (const token of tokens) {
44+
const last = out[out.length - 1];
45+
if (last && last.name === token.name && (token.name === 'stringChunk' || token.name === 'numberChunk' || token.name === 'commentChunk')) {
46+
out[out.length - 1] = {name: last.name, value: last.value + token.value};
47+
} else {
48+
out.push(token);
49+
}
50+
}
51+
return out.filter(token => token.name !== 'whitespace');
52+
};
53+
54+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
55+
const json = () => fc.jsonValue({maxDepth: 4});
56+
const offsets = () => fc.array(fc.nat(4096), {maxLength: 12});
57+
58+
test('parser: chunk boundaries do not change the token stream', async t => {
59+
await t.prop(
60+
[json(), offsets()],
61+
async (value, cuts) => {
62+
const text = JSON.stringify(value);
63+
return same(await tokensOf([text], parser, {streamValues: false}), await tokensOf(splitAt(text, cuts), parser, {streamValues: false}));
64+
},
65+
{numRuns: 100},
66+
'packed tokens are split-invariant'
67+
);
68+
await t.prop(
69+
[json(), offsets()],
70+
async (value, cuts) => {
71+
const text = JSON.stringify(value);
72+
return same(coalesce(await tokensOf([text], parser)), coalesce(await tokensOf(splitAt(text, cuts), parser)));
73+
},
74+
{numRuns: 100},
75+
'streamed chunks coalesce to the same tokens'
76+
);
77+
});
78+
79+
test('parser → assembler round-trips against JSON.parse', async t => {
80+
await t.prop(
81+
[json(), offsets()],
82+
async (value, cuts) => {
83+
const asm = new Assembler();
84+
for (const token of await tokensOf(splitAt(JSON.stringify(value), cuts), parser)) asm.consume(token);
85+
return same(asm.current, value);
86+
},
87+
{numRuns: 100},
88+
'assembled value equals the original'
89+
);
90+
});
91+
92+
test('disassembler → stringer round-trips against JSON.stringify', async t => {
93+
await t.prop(
94+
[json()],
95+
value => {
96+
const write = stringer();
97+
let text = '';
98+
for (const token of disassembler()(value)) {
99+
const piece = write(token);
100+
if (piece !== none) text += piece;
101+
}
102+
const tail = write(none);
103+
if (tail !== none) text += tail;
104+
return same(JSON.parse(text), value);
105+
},
106+
{numRuns: 200},
107+
'stringer output parses back to the original'
108+
);
109+
});
110+
111+
// JSONC: comments and whitespace inserted between tokens, then split anywhere —
112+
// comments may straddle chunk boundaries, including their delimiters
113+
const commentBody = () => fc.string({maxLength: 12}).filter(s => !s.includes('*/') && !/[\r\n]/.test(s));
114+
const decorate = (pretty, kinds, bodies) => {
115+
let i = 0;
116+
return pretty.replace(/\n/g, () => {
117+
const kind = kinds[i % kinds.length],
118+
body = bodies[i % bodies.length];
119+
++i;
120+
return kind === 1 ? ' /*' + body + '*/\n' : kind === 2 ? ' //' + body + '\n' : '\n';
121+
});
122+
};
123+
124+
test('jsonc parser: comments survive arbitrary chunk boundaries', async t => {
125+
await t.prop(
126+
[json(), fc.array(fc.constantFrom(0, 1, 2), {minLength: 1, maxLength: 8}), fc.array(commentBody(), {minLength: 1, maxLength: 8}), offsets()],
127+
async (value, kinds, bodies, cuts) => {
128+
const text = decorate(JSON.stringify(value, null, 1), kinds, bodies);
129+
const whole = coalesce(await tokensOf([text], jsoncParser)),
130+
chunked = coalesce(await tokensOf(splitAt(text, cuts), jsoncParser));
131+
if (!same(whole, chunked)) return false;
132+
const asm = new Assembler();
133+
for (const token of chunked) asm.consume(token);
134+
return same(asm.current, value);
135+
},
136+
{numRuns: 100},
137+
'tokens and comments are split-invariant'
138+
);
139+
});

0 commit comments

Comments
 (0)