functionalscript 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fjs/ci/config/module.f.d.ts +3 -3
- package/fjs/ci/config/module.f.js +3 -3
- package/fjs/ci/deno/module.f.d.ts +8 -0
- package/fjs/ci/deno/module.f.js +9 -1
- package/fjs/ci/deno/proof.f.d.ts +5 -0
- package/fjs/ci/deno/proof.f.js +26 -0
- package/fjs/ci/proof.f.js +3 -2
- package/fjs/dev/module.f.d.ts +15 -4
- package/fjs/dev/module.f.js +38 -7
- package/fjs/dev/package_json/module.f.js +7 -4
- package/fjs/dev/proof.f.js +10 -1
- package/fjs/djs/parser/module.f.js +13 -3
- package/fjs/djs/parser/proof.f.d.ts +1 -0
- package/fjs/djs/parser/proof.f.js +26 -0
- package/fjs/djs/tokenizer/proof.f.js +11 -4
- package/fjs/emergent_testing/proof.f.js +20 -3
- package/fjs/js/tokenizer/proof.f.js +7 -0
- package/fjs/mcp/cas/module.f.js +4 -1
- package/fjs/mcp/evo/proof.f.js +6 -1
- package/fjs/mcp/proof.f.js +37 -18
- package/fjs/media/json/module.f.d.ts +15 -1
- package/fjs/media/json/module.f.js +17 -1
- package/fjs/media/json/parser/module.f.js +13 -3
- package/fjs/media/json/parser/proof.f.d.ts +1 -0
- package/fjs/media/json/parser/proof.f.js +37 -1
- package/fjs/media/json/proof.f.d.ts +4 -0
- package/fjs/media/json/proof.f.js +14 -2
- package/fjs/media/json/serializer/module.f.d.ts +4 -0
- package/fjs/media/json/serializer/module.f.js +39 -2
- package/fjs/media/json/serializer/proof.f.js +32 -8
- package/fjs/media/module.f.d.ts +92 -10
- package/fjs/media/module.f.js +73 -8
- package/fjs/media/proof.f.d.ts +10 -0
- package/fjs/media/proof.f.js +79 -10
- package/fjs/media/revision/module.f.d.ts +9 -1
- package/fjs/media/revision/module.f.js +14 -5
- package/fjs/protocol/mcp/stdio/module.f.js +2 -5
- package/fjs/text/utf16/module.f.d.ts +24 -0
- package/fjs/text/utf16/module.f.js +24 -0
- package/fjs/text/utf16/proof.f.d.ts +1 -0
- package/fjs/text/utf16/proof.f.js +10 -1
- package/fjs/types/rtti/validate/proof.f.d.ts +2 -0
- package/fjs/types/rtti/validate/proof.f.js +58 -0
- package/nanvm-lib/tests/proof.f.d.ts +9 -0
- package/nanvm-lib/tests/proof.f.js +58 -7
- package/package.json +2 -2
package/fjs/mcp/proof.f.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { assert, assertEq } from '../asserts/module.f.js';
|
|
2
2
|
import { pure, step } from '../effects/module.f.js';
|
|
3
3
|
import { create } from '../effects/memory/module.f.js';
|
|
4
|
+
import { parse as parseJson } from '../media/json/module.f.js';
|
|
5
|
+
import { number as rttiNumber, option, string as rttiString } from '../types/rtti/module.f.js';
|
|
6
|
+
import { parse as rttiParse } from '../types/rtti/parse/module.f.js';
|
|
4
7
|
import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from '../types/bit_vec/module.f.js';
|
|
5
8
|
import { vecToCBase32 } from '../basen/cbase32/module.f.js';
|
|
6
9
|
import { encode as base64Encode } from '../basen/base64/module.f.js';
|
|
@@ -12,10 +15,24 @@ import { nonEmpty, empty as elEmpty } from '../effects/list/module.f.js';
|
|
|
12
15
|
import { mcpStep, uninitializedState, } from '../protocol/mcp/module.f.js';
|
|
13
16
|
import { emptyState, virtual } from '../effects/node/virtual/module.f.js';
|
|
14
17
|
import { casConfig, casMcpHandlers } from './module.f.js';
|
|
15
|
-
import { ok as resultOk } from '../types/result/module.f.js';
|
|
18
|
+
import { ok as resultOk, unwrap } from '../types/result/module.f.js';
|
|
16
19
|
import { stdioTransport } from '../protocol/mcp/stdio/module.f.js';
|
|
17
20
|
import { fromVec } from '../types/uint8array/module.f.js';
|
|
18
21
|
import { initEvo } from '../cas/evo/module.f.js';
|
|
22
|
+
// `cas_get`'s result JSON. As an rtti schema it both describes the shape and
|
|
23
|
+
// checks it, so reading a field needs neither a hand-written type nor an `as`
|
|
24
|
+
// cast — a response that drifts from this fails the test at the parse, not
|
|
25
|
+
// silently at the assertion. `uri` is always emitted; only the payload
|
|
26
|
+
// (`text` for `type: 'text'`, `blob` for `type: 'base64'`) is conditional.
|
|
27
|
+
const casGetResult = {
|
|
28
|
+
length: rttiNumber,
|
|
29
|
+
mimeType: rttiString,
|
|
30
|
+
type: rttiString,
|
|
31
|
+
uri: rttiString,
|
|
32
|
+
text: option(rttiString),
|
|
33
|
+
blob: option(rttiString),
|
|
34
|
+
};
|
|
35
|
+
const parseCasGetResult = rttiParse(casGetResult);
|
|
19
36
|
// ── Session driver ──────────────────────────────────────────────────────────────
|
|
20
37
|
// Feeds each message to `handler` in order, collecting every response.
|
|
21
38
|
const feed = (handler) => (msgs) => {
|
|
@@ -70,11 +87,13 @@ const runStdio = (root, home = '/home/user') => (msgs) => {
|
|
|
70
87
|
const stdout = virtual({ ...emptyState, root, stdin: toBytes(input) })(effect)[0].stdout;
|
|
71
88
|
// Only requests get a written line (notifications, like `initialized`,
|
|
72
89
|
// write nothing) — drop the `init` response, keep one line per `msgs` entry.
|
|
73
|
-
return stdout.split('\n').filter(line => line.length > 0).slice(1).map(line =>
|
|
90
|
+
return stdout.split('\n').filter(line => line.length > 0).slice(1).map(line => unwrap(parseJson(line)));
|
|
74
91
|
};
|
|
75
92
|
const resultOf = (resp) => resp.result;
|
|
76
93
|
const item0 = (resp) => resultOf(resp).content[0];
|
|
77
94
|
const textOf = (resp) => item0(resp).text;
|
|
95
|
+
/** The `text` payload of a `cas_get` response, parsed and checked against {@link casGetResult}. */
|
|
96
|
+
const casGetResultOf = (resp) => unwrap(parseCasGetResult(unwrap(parseJson(textOf(resp)))));
|
|
78
97
|
// A plain text sample for text add→get round-trips.
|
|
79
98
|
const textSample = 'hello, world!';
|
|
80
99
|
// A valid `vnd.fjs.revision` blob: every required field present, including the
|
|
@@ -115,7 +134,7 @@ const largeMultiChunkBlobMeta = (chunk0, chunk1, expectedType, expectedMime) =>
|
|
|
115
134
|
call(2, 'cas_get', { hash }),
|
|
116
135
|
]).slice(2);
|
|
117
136
|
assert(!resultOf(metaResp).isError);
|
|
118
|
-
const meta =
|
|
137
|
+
const meta = casGetResultOf(metaResp);
|
|
119
138
|
assertEq(meta.type, expectedType);
|
|
120
139
|
assertEq(meta.mimeType, expectedMime);
|
|
121
140
|
assertEq(meta.length, Number((length(chunk0) + length(chunk1)) / 8n));
|
|
@@ -204,7 +223,7 @@ export const proof = {
|
|
|
204
223
|
call(2, 'cas_get', { hash, content: true }),
|
|
205
224
|
]);
|
|
206
225
|
assert(!resultOf(getResp).isError);
|
|
207
|
-
const result =
|
|
226
|
+
const result = casGetResultOf(getResp);
|
|
208
227
|
assertEq(result.type, 'base64');
|
|
209
228
|
assertEq(result.length, 90_000);
|
|
210
229
|
},
|
|
@@ -274,7 +293,7 @@ export const proof = {
|
|
|
274
293
|
const hash = textOf(addResp);
|
|
275
294
|
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash }));
|
|
276
295
|
assert(!resultOf(getResp).isError);
|
|
277
|
-
const result =
|
|
296
|
+
const result = casGetResultOf(getResp);
|
|
278
297
|
assertEq(result.mimeType, 'text/plain');
|
|
279
298
|
assertEq(result.type, 'text');
|
|
280
299
|
assertEq(result.length, textSample.length);
|
|
@@ -286,7 +305,7 @@ export const proof = {
|
|
|
286
305
|
const hash = textOf(addResp);
|
|
287
306
|
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
288
307
|
assert(!resultOf(getResp).isError);
|
|
289
|
-
const result =
|
|
308
|
+
const result = casGetResultOf(getResp);
|
|
290
309
|
assertEq(result.type, 'text');
|
|
291
310
|
assertEq(result.mimeType, 'text/plain');
|
|
292
311
|
assertEq(result.text, textSample);
|
|
@@ -297,7 +316,7 @@ export const proof = {
|
|
|
297
316
|
const hash = textOf(addResp);
|
|
298
317
|
const [, getResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
299
318
|
assert(!resultOf(getResp).isError);
|
|
300
|
-
const result =
|
|
319
|
+
const result = casGetResultOf(getResp);
|
|
301
320
|
assertEq(result.text, undefined);
|
|
302
321
|
assertEq(result.type, 'text');
|
|
303
322
|
assertEq(result.mimeType, 'text/plain');
|
|
@@ -308,7 +327,7 @@ export const proof = {
|
|
|
308
327
|
const hash = textOf(addResp);
|
|
309
328
|
const [, getResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
310
329
|
assert(!resultOf(getResp).isError);
|
|
311
|
-
const result =
|
|
330
|
+
const result = casGetResultOf(getResp);
|
|
312
331
|
assertEq(result.type, 'text');
|
|
313
332
|
assertEq(result.text, '*');
|
|
314
333
|
},
|
|
@@ -317,7 +336,7 @@ export const proof = {
|
|
|
317
336
|
const hash = textOf(addResp);
|
|
318
337
|
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
319
338
|
assert(!resultOf(getResp).isError);
|
|
320
|
-
const result =
|
|
339
|
+
const result = casGetResultOf(getResp);
|
|
321
340
|
assertEq(result.text, textSample);
|
|
322
341
|
},
|
|
323
342
|
// Text content (no magic-byte match, valid UTF-8) comes back with type:'text' when content requested.
|
|
@@ -326,7 +345,7 @@ export const proof = {
|
|
|
326
345
|
const hash = textOf(addResp);
|
|
327
346
|
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
328
347
|
assertEq(item0(getResp) === null ? null : item0(getResp).type, 'text');
|
|
329
|
-
const result =
|
|
348
|
+
const result = casGetResultOf(getResp);
|
|
330
349
|
assertEq(result.type, 'text');
|
|
331
350
|
assertEq(result.mimeType, 'text/plain');
|
|
332
351
|
},
|
|
@@ -337,7 +356,7 @@ export const proof = {
|
|
|
337
356
|
const [, getResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
338
357
|
assert(!resultOf(getResp).isError);
|
|
339
358
|
assertEq(item0(getResp).type, 'text');
|
|
340
|
-
const result =
|
|
359
|
+
const result = casGetResultOf(getResp);
|
|
341
360
|
assertEq(result.type, 'base64');
|
|
342
361
|
assertEq(result.mimeType, 'image/png');
|
|
343
362
|
assertEq(result.blob, pngSample);
|
|
@@ -351,7 +370,7 @@ export const proof = {
|
|
|
351
370
|
const hash = textOf(addResp);
|
|
352
371
|
const [, metaResp] = session(call(2, 'cas_add', { content: revisionSample }), call(3, 'cas_get', { hash }));
|
|
353
372
|
assert(!resultOf(metaResp).isError);
|
|
354
|
-
const meta =
|
|
373
|
+
const meta = casGetResultOf(metaResp);
|
|
355
374
|
assertEq(meta.mimeType, revisionMediaType);
|
|
356
375
|
assertEq(meta.type, 'text');
|
|
357
376
|
},
|
|
@@ -362,7 +381,7 @@ export const proof = {
|
|
|
362
381
|
const hash = textOf(addResp);
|
|
363
382
|
const [, getResp] = session(call(2, 'cas_add', { content: revisionSample }), call(3, 'cas_get', { hash, content: true }));
|
|
364
383
|
assert(!resultOf(getResp).isError);
|
|
365
|
-
const result =
|
|
384
|
+
const result = casGetResultOf(getResp);
|
|
366
385
|
assertEq(result.mimeType, revisionMediaType);
|
|
367
386
|
assertEq(result.type, 'text');
|
|
368
387
|
assertEq(result.text, revisionSample);
|
|
@@ -449,7 +468,7 @@ export const proof = {
|
|
|
449
468
|
const hash = textOf(addResp);
|
|
450
469
|
const [, metaResp] = session(call(2, 'cas_add', { content: 'text content' }), call(3, 'cas_get', { hash }));
|
|
451
470
|
assert(!resultOf(metaResp).isError);
|
|
452
|
-
const meta =
|
|
471
|
+
const meta = casGetResultOf(metaResp);
|
|
453
472
|
assertEq(meta.mimeType, 'text/plain');
|
|
454
473
|
assertEq(meta.type, 'text');
|
|
455
474
|
assertEq(meta.length, 12);
|
|
@@ -460,7 +479,7 @@ export const proof = {
|
|
|
460
479
|
const hash = textOf(addResp);
|
|
461
480
|
const [, metaResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
462
481
|
assert(!resultOf(metaResp).isError);
|
|
463
|
-
const meta =
|
|
482
|
+
const meta = casGetResultOf(metaResp);
|
|
464
483
|
assertEq(meta.mimeType, 'image/png');
|
|
465
484
|
assertEq(meta.type, 'base64');
|
|
466
485
|
assertEq(meta.length, 10);
|
|
@@ -473,7 +492,7 @@ export const proof = {
|
|
|
473
492
|
const hash = textOf(addResp);
|
|
474
493
|
const [, metaResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
475
494
|
assert(!resultOf(metaResp).isError);
|
|
476
|
-
const meta =
|
|
495
|
+
const meta = casGetResultOf(metaResp);
|
|
477
496
|
assertEq(meta.mimeType, 'application/octet-stream');
|
|
478
497
|
assertEq(meta.type, 'base64');
|
|
479
498
|
assertEq(meta.blob, undefined);
|
|
@@ -487,7 +506,7 @@ export const proof = {
|
|
|
487
506
|
const hash = textOf(addResp);
|
|
488
507
|
const [, metaResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
489
508
|
assert(!resultOf(metaResp).isError);
|
|
490
|
-
const meta =
|
|
509
|
+
const meta = casGetResultOf(metaResp);
|
|
491
510
|
assertEq(meta.mimeType, 'application/octet-stream');
|
|
492
511
|
assertEq(meta.type, 'base64');
|
|
493
512
|
},
|
|
@@ -508,7 +527,7 @@ export const proof = {
|
|
|
508
527
|
const hash = textOf(addResp);
|
|
509
528
|
const [, getResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
510
529
|
assert(!resultOf(getResp).isError);
|
|
511
|
-
const result =
|
|
530
|
+
const result = casGetResultOf(getResp);
|
|
512
531
|
assertEq(result.mimeType, 'application/octet-stream');
|
|
513
532
|
assertEq(result.type, 'base64');
|
|
514
533
|
assertEq(result.blob, binaryB64);
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* JSON value types, rtti schemas, and utilities: `serialize`, `stringify`,
|
|
3
3
|
* `parse`, and `setProperty` for immutable nested updates.
|
|
4
4
|
*
|
|
5
|
+
* `parse` is the total, `Result`-returning `text → Unknown` entry point built
|
|
6
|
+
* on this module's own tokenizer and parser.
|
|
7
|
+
*
|
|
5
8
|
* The JSON value types (`Unknown`, `Primitive`) are derived from the rtti
|
|
6
9
|
* schemas defined here, so the schema is the single source of truth — no
|
|
7
10
|
* hand-written types to keep in sync.
|
|
@@ -9,6 +12,7 @@
|
|
|
9
12
|
* @module
|
|
10
13
|
*/
|
|
11
14
|
import { type List } from '../../types/list/module.f.ts';
|
|
15
|
+
import type { Result } from '../../types/result/module.f.ts';
|
|
12
16
|
import { type Entry as ObjectEntry } from '../../types/object/module.f.ts';
|
|
13
17
|
import type { Ts } from '../../types/rtti/ts/module.f.ts';
|
|
14
18
|
/** rtti schema matching any JSON primitive: `null`, `boolean`, `number`, or `string`. */
|
|
@@ -46,5 +50,15 @@ export declare const serialize: (mapEntries: MapEntries) => (value: Unknown) =>
|
|
|
46
50
|
* https://tc39.es/ecma262/#sec-serializejsonproperty
|
|
47
51
|
*/
|
|
48
52
|
export declare const stringify: (mapEntries: MapEntries) => (value: Unknown) => string;
|
|
49
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Parses `text` as JSON with this module's own pure tokenizer and parser,
|
|
55
|
+
* reporting failure as a `Result` rather than throwing: malformed input is
|
|
56
|
+
* *available* as an `error` to branch on. Whether to branch or to `unwrap` it
|
|
57
|
+
* back into a panic is the caller's decision — the parser no longer makes it
|
|
58
|
+
* for them.
|
|
59
|
+
*
|
|
60
|
+
* The result is an untyped {@link Unknown}; narrow it to a domain type with an
|
|
61
|
+
* rtti schema (`fjs/types/rtti/parse`) rather than with an `as` cast.
|
|
62
|
+
*/
|
|
63
|
+
export declare const parse: (text: string) => Result<Unknown, string>;
|
|
50
64
|
export {};
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* JSON value types, rtti schemas, and utilities: `serialize`, `stringify`,
|
|
3
3
|
* `parse`, and `setProperty` for immutable nested updates.
|
|
4
4
|
*
|
|
5
|
+
* `parse` is the total, `Result`-returning `text → Unknown` entry point built
|
|
6
|
+
* on this module's own tokenizer and parser.
|
|
7
|
+
*
|
|
5
8
|
* The JSON value types (`Unknown`, `Primitive`) are derived from the rtti
|
|
6
9
|
* schemas defined here, so the schema is the single source of truth — no
|
|
7
10
|
* hand-written types to keep in sync.
|
|
@@ -10,6 +13,9 @@
|
|
|
10
13
|
*/
|
|
11
14
|
import { next, flat, map } from '../../types/list/module.f.js';
|
|
12
15
|
import { concat } from '../../types/string/module.f.js';
|
|
16
|
+
import { stringToList } from '../../text/utf16/module.f.js';
|
|
17
|
+
import { parse as parseTokens } from './parser/module.f.js';
|
|
18
|
+
import { tokenize } from './tokenizer/module.f.js';
|
|
13
19
|
import { at, definedEntries } from '../../types/object/module.f.js';
|
|
14
20
|
import { compose, fn } from '../../types/function/module.f.js';
|
|
15
21
|
import { objectWrap, arrayWrap, stringSerialize, numberSerialize, nullSerialize, boolSerialize } from './serializer/module.f.js';
|
|
@@ -90,4 +96,14 @@ export const serialize = sort => {
|
|
|
90
96
|
* https://tc39.es/ecma262/#sec-serializejsonproperty
|
|
91
97
|
*/
|
|
92
98
|
export const stringify = sort => compose(serialize(sort))(concat);
|
|
93
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Parses `text` as JSON with this module's own pure tokenizer and parser,
|
|
101
|
+
* reporting failure as a `Result` rather than throwing: malformed input is
|
|
102
|
+
* *available* as an `error` to branch on. Whether to branch or to `unwrap` it
|
|
103
|
+
* back into a panic is the caller's decision — the parser no longer makes it
|
|
104
|
+
* for them.
|
|
105
|
+
*
|
|
106
|
+
* The result is an untyped {@link Unknown}; narrow it to a domain type with an
|
|
107
|
+
* rtti schema (`fjs/types/rtti/parse`) rather than with an `as` cast.
|
|
108
|
+
*/
|
|
109
|
+
export const parse = text => parseTokens(tokenize(stringToList(text)));
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
import { error, ok } from '../../../types/result/module.f.js';
|
|
7
|
-
import { fold,
|
|
7
|
+
import { fold, next, toArray, concat } from '../../../types/list/module.f.js';
|
|
8
8
|
import {} from '../../../types/function/operator/module.f.js';
|
|
9
9
|
import {} from '../tokenizer/module.f.js';
|
|
10
10
|
import { setReplace } from '../../../types/ordered_map/module.f.js';
|
|
@@ -33,9 +33,19 @@ const startArray = state => {
|
|
|
33
33
|
const newStack = state.top === null ? null : { first: state.top, tail: state.stack };
|
|
34
34
|
return { status: '[', top: { kind: 'array', values: null }, stack: newStack };
|
|
35
35
|
};
|
|
36
|
+
// Pops the enclosing container off `stack`. `next` is forced here rather than
|
|
37
|
+
// left as a `drop(1)` thunk: the stack is written only by startArray/startObject,
|
|
38
|
+
// always as a literal cons, and a lazy pop would leave one unforced thunk per
|
|
39
|
+
// closed container — a chain that overflows the stack when it is finally forced.
|
|
40
|
+
const popStack = stack => {
|
|
41
|
+
const ne = next(stack);
|
|
42
|
+
return ne === null
|
|
43
|
+
? { status: '', top: null, stack: null }
|
|
44
|
+
: { status: '', top: ne.first, stack: ne.tail };
|
|
45
|
+
};
|
|
36
46
|
const endArray = state => {
|
|
37
47
|
const array = state.top !== null ? toArray(state.top.values) : null;
|
|
38
|
-
const newState =
|
|
48
|
+
const newState = popStack(state.stack);
|
|
39
49
|
return pushValue(newState)(array);
|
|
40
50
|
};
|
|
41
51
|
const startObject = state => {
|
|
@@ -44,7 +54,7 @@ const startObject = state => {
|
|
|
44
54
|
};
|
|
45
55
|
const endObject = state => {
|
|
46
56
|
const obj = state.top?.kind === 'object' ? fromMap(state.top.values) : null;
|
|
47
|
-
const newState =
|
|
57
|
+
const newState = popStack(state.stack);
|
|
48
58
|
return pushValue(newState)(obj);
|
|
49
59
|
};
|
|
50
60
|
const tokenToValue = token => {
|
|
@@ -274,5 +274,41 @@ export const proof = {
|
|
|
274
274
|
const result = stringify(obj);
|
|
275
275
|
assertEq(result, '["error","unexpected token"]');
|
|
276
276
|
},
|
|
277
|
-
]
|
|
277
|
+
],
|
|
278
|
+
// Regression: closing a container used to pop the parser stack lazily
|
|
279
|
+
// (`drop(1)`, which is `apply(dropStep)` and returns a `Thunk`), leaving one
|
|
280
|
+
// unforced thunk per closed container. The chain was forced only at the end,
|
|
281
|
+
// costing a call-stack frame per container and overflowing at roughly 5000 of
|
|
282
|
+
// them — nested or flat siblings alike — while primitives were unbounded,
|
|
283
|
+
// since they never push or pop. `popStack` in `module.f.ts` forces the pop
|
|
284
|
+
// instead, which is why these sizes are safe now.
|
|
285
|
+
siblingContainers: [
|
|
286
|
+
() => {
|
|
287
|
+
const [tag, value] = parse(tokenizeString(`[${Array(6000).fill('{}').join(',')}]`));
|
|
288
|
+
assertEq(tag, 'ok');
|
|
289
|
+
assertEq(Array.isArray(value) ? value.length : -1, 6000);
|
|
290
|
+
},
|
|
291
|
+
() => {
|
|
292
|
+
const [tag, value] = parse(tokenizeString(`[${Array(6000).fill('[]').join(',')}]`));
|
|
293
|
+
assertEq(tag, 'ok');
|
|
294
|
+
assertEq(Array.isArray(value) ? value.length : -1, 6000);
|
|
295
|
+
},
|
|
296
|
+
() => {
|
|
297
|
+
const keys = Array.from({ length: 6000 }, (_, i) => `"k${i}":{}`);
|
|
298
|
+
const [tag, value] = parse(tokenizeString(`{${keys.join(',')}}`));
|
|
299
|
+
assertEq(tag, 'ok');
|
|
300
|
+
assertEq(typeof value === 'object' && value !== null ? Object.keys(value).length : -1, 6000);
|
|
301
|
+
},
|
|
302
|
+
() => {
|
|
303
|
+
// deep nesting shares the same stack path and overflowed at 5000
|
|
304
|
+
const [tag] = parse(tokenizeString('['.repeat(5000) + ']'.repeat(5000)));
|
|
305
|
+
assertEq(tag, 'ok');
|
|
306
|
+
},
|
|
307
|
+
() => {
|
|
308
|
+
// baseline that always worked: primitives never touch the stack
|
|
309
|
+
const [tag, value] = parse(tokenizeString(`[${Array.from({ length: 12000 }, (_, i) => i).join(',')}]`));
|
|
310
|
+
assertEq(tag, 'ok');
|
|
311
|
+
assertEq(Array.isArray(value) ? value.length : -1, 12000);
|
|
312
|
+
},
|
|
313
|
+
],
|
|
278
314
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { setProperty, stringify } from './module.f.js';
|
|
1
|
+
import { parse, setProperty, stringify } from './module.f.js';
|
|
2
2
|
import { sort } from '../../types/object/module.f.js';
|
|
3
3
|
import { identity } from '../../types/function/module.f.js';
|
|
4
4
|
import { assertEq } from '../../asserts/module.f.js';
|
|
@@ -82,5 +82,17 @@ export const proof = {
|
|
|
82
82
|
],
|
|
83
83
|
undefined: () => {
|
|
84
84
|
assertEq(stringify(sort)({ x: undefined }), '{}');
|
|
85
|
-
}
|
|
85
|
+
},
|
|
86
|
+
parse: {
|
|
87
|
+
ok: () => {
|
|
88
|
+
const [t, v] = parse('{"a":[1,true,null],"b":"x"}');
|
|
89
|
+
assertEq(t, 'ok');
|
|
90
|
+
assertEq(stringify(sort)(v), '{"a":[1,true,null],"b":"x"}');
|
|
91
|
+
},
|
|
92
|
+
// Malformed input is an error value, not a throw.
|
|
93
|
+
error: () => {
|
|
94
|
+
const [t] = parse('{');
|
|
95
|
+
assertEq(t, 'error');
|
|
96
|
+
},
|
|
97
|
+
},
|
|
86
98
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JSON serializer for deterministic string output.
|
|
3
3
|
*
|
|
4
|
+
* `stringSerialize` is FunctionalScript, not the host's `JSON.stringify`: it
|
|
5
|
+
* escapes over this repository's own UTF-16 decoder and reproduces the
|
|
6
|
+
* ECMAScript `QuoteJSONString` result exactly, lone surrogates included.
|
|
7
|
+
*
|
|
4
8
|
* @module
|
|
5
9
|
*/
|
|
6
10
|
import { type List } from '../../../types/list/module.f.ts';
|
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JSON serializer for deterministic string output.
|
|
3
3
|
*
|
|
4
|
+
* `stringSerialize` is FunctionalScript, not the host's `JSON.stringify`: it
|
|
5
|
+
* escapes over this repository's own UTF-16 decoder and reproduces the
|
|
6
|
+
* ECMAScript `QuoteJSONString` result exactly, lone surrogates included.
|
|
7
|
+
*
|
|
4
8
|
* @module
|
|
5
9
|
*/
|
|
6
|
-
import { flat, reduce, empty } from '../../../types/list/module.f.js';
|
|
10
|
+
import { flat, map, reduce, empty } from '../../../types/list/module.f.js';
|
|
7
11
|
import {} from '../../../types/function/operator/module.f.js';
|
|
12
|
+
import { concat } from '../../../types/string/module.f.js';
|
|
13
|
+
import { codePointToString, stringToCodePointList, } from '../../../text/utf16/module.f.js';
|
|
14
|
+
import { errorMask } from '../../../text/code_point/module.f.js';
|
|
15
|
+
import { backspace, cr, digit0, ff, ht, latinSmallLetterA, lf, quotationMark, reverseSolidus, space, } from '../../../text/ascii/module.f.js';
|
|
8
16
|
const jsonStringify = JSON.stringify;
|
|
17
|
+
const { fromCharCode } = String;
|
|
18
|
+
/**
|
|
19
|
+
* The code points JSON gives a two-character escape. Every other code point
|
|
20
|
+
* below `space` has no short form and goes through `unicodeEscape` instead.
|
|
21
|
+
*/
|
|
22
|
+
const escapeTable = {
|
|
23
|
+
[backspace]: '\\b',
|
|
24
|
+
[ht]: '\\t',
|
|
25
|
+
[lf]: '\\n',
|
|
26
|
+
[ff]: '\\f',
|
|
27
|
+
[cr]: '\\r',
|
|
28
|
+
[quotationMark]: '\\"',
|
|
29
|
+
[reverseSolidus]: '\\\\',
|
|
30
|
+
};
|
|
31
|
+
const hexDigit = (value) => fromCharCode(value < 10 ? digit0 + value : latinSmallLetterA + value - 10);
|
|
32
|
+
/**
|
|
33
|
+
* `\uXXXX` with lowercase hex digits, matching ECMAScript's `UnicodeEscape`.
|
|
34
|
+
*/
|
|
35
|
+
const unicodeEscape = (unit) => `\\u${hexDigit(unit >> 12 & 0xf)}${hexDigit(unit >> 8 & 0xf)}${hexDigit(unit >> 4 & 0xf)}${hexDigit(unit & 0xf)}`;
|
|
36
|
+
/**
|
|
37
|
+
* Escapes one decoded code point. A code point tagged with `errorMask` is an
|
|
38
|
+
* unpaired surrogate, which well-formed JSON stringification (ES2019) emits as
|
|
39
|
+
* its `\uXXXX` escape rather than as a code unit; everything else is either a
|
|
40
|
+
* named escape, a `\u00XX` control escape, or the character itself.
|
|
41
|
+
*/
|
|
42
|
+
const escapeCodePoint = (codePoint) => (codePoint & errorMask) !== 0
|
|
43
|
+
? unicodeEscape(codePoint & 0xffff)
|
|
44
|
+
: escapeTable[codePoint]
|
|
45
|
+
?? (codePoint < space ? unicodeEscape(codePoint) : codePointToString(codePoint));
|
|
9
46
|
/**
|
|
10
47
|
* Serializes a string as a JSON string literal.
|
|
11
48
|
*/
|
|
12
|
-
export const stringSerialize = input => [
|
|
49
|
+
export const stringSerialize = input => [`"${concat(map(escapeCodePoint)(stringToCodePointList(input)))}"`];
|
|
13
50
|
/**
|
|
14
51
|
* Serializes a number as a JSON number literal.
|
|
15
52
|
*/
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { arrayWrap, boolSerialize, numberSerialize, objectWrap, stringSerialize } from './module.f.js';
|
|
2
2
|
import * as list from '../../../types/list/module.f.js';
|
|
3
|
+
import { concat } from '../../../types/string/module.f.js';
|
|
3
4
|
import { assertEq } from '../../../asserts/module.f.js';
|
|
4
5
|
const { toArray } = list;
|
|
6
|
+
// The expected literals below are what the host's `JSON.stringify` produces for
|
|
7
|
+
// the same input; `stringSerialize` has to reproduce them exactly, so any
|
|
8
|
+
// divergence in the FunctionalScript escaping shows up here as a failure.
|
|
9
|
+
const serialized = (input) => concat(stringSerialize(input));
|
|
5
10
|
export const proof = {
|
|
6
11
|
arrayWrap: [
|
|
7
12
|
() => {
|
|
@@ -38,14 +43,33 @@ export const proof = {
|
|
|
38
43
|
}
|
|
39
44
|
],
|
|
40
45
|
stringSerialize: [
|
|
41
|
-
() => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
46
|
+
() => { assertEq(serialized('abc'), '"abc"'); },
|
|
47
|
+
() => { assertEq(serialized('123'), '"123"'); },
|
|
48
|
+
() => { assertEq(serialized(''), '""'); },
|
|
49
|
+
// one chunk, like every other leaf serializer in this module
|
|
50
|
+
() => { assertEq(toArray(stringSerialize('a"')).length, 1); },
|
|
51
|
+
// the two escapes JSON requires outside the control block
|
|
52
|
+
() => { assertEq(serialized('a"b'), '"a\\"b"'); },
|
|
53
|
+
() => { assertEq(serialized('a\\b'), '"a\\\\b"'); },
|
|
54
|
+
// every named control escape
|
|
55
|
+
() => { assertEq(serialized('\b\f\n\r\t'), '"\\b\\f\\n\\r\\t"'); },
|
|
56
|
+
// control code points without a named escape, covering both hex-digit
|
|
57
|
+
// halves: '0'-'9' and 'a'-'f'
|
|
58
|
+
() => { assertEq(serialized('\u0000'), '"\\u0000"'); },
|
|
59
|
+
() => { assertEq(serialized('\u000b'), '"\\u000b"'); },
|
|
60
|
+
() => { assertEq(serialized('\u001f'), '"\\u001f"'); },
|
|
61
|
+
// `space` is the first code point copied through unescaped, and DEL is
|
|
62
|
+
// not a JSON escape at all
|
|
63
|
+
() => { assertEq(serialized(' \u007f'), '" \u007f"'); },
|
|
64
|
+
// a surrogate pair decodes to one code point and survives unchanged
|
|
65
|
+
() => { assertEq(serialized('😀'), '"😀"'); },
|
|
66
|
+
// unpaired surrogates, well-formed-stringify escaped: leading, trailing,
|
|
67
|
+
// in the middle, doubled, and left over at end of input
|
|
68
|
+
() => { assertEq(serialized('\ud800'), '"\\ud800"'); },
|
|
69
|
+
() => { assertEq(serialized('\udfff'), '"\\udfff"'); },
|
|
70
|
+
() => { assertEq(serialized('a\udc00b'), '"a\\udc00b"'); },
|
|
71
|
+
() => { assertEq(serialized('\ud800\ud800'), '"\\ud800\\ud800"'); },
|
|
72
|
+
() => { assertEq(serialized('a\ud83d'), '"a\\ud83d"'); },
|
|
49
73
|
],
|
|
50
74
|
numberSerialize: [
|
|
51
75
|
() => {
|
package/fjs/media/module.f.d.ts
CHANGED
|
@@ -5,17 +5,31 @@
|
|
|
5
5
|
* `fjs/media/type` classifies raw bytes (magic-byte signatures, UTF-8
|
|
6
6
|
* text-vs-binary) with no notion of a JSON dialect. `detect` here adds one
|
|
7
7
|
* more classification step in front of it: when the whole blob is
|
|
8
|
-
* whole-blob-valid UTF-8 text (as `fjs/media/type` already determined),
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* or doesn't validate against a known dialect, falls through to the
|
|
8
|
+
* whole-blob-valid UTF-8 text (as `fjs/media/type` already determined), parse
|
|
9
|
+
* it as JSON and match the parsed value against the dialects the caller
|
|
10
|
+
* registered. A match reports that dialect's derived media type; anything that
|
|
11
|
+
* isn't valid JSON, or matches no registered dialect, falls through to the
|
|
13
12
|
* `fjs/media/type` verdict unchanged.
|
|
14
13
|
*
|
|
14
|
+
* The dialect set is a **parameter**, not an import: `detect(dialects)(bytes)`.
|
|
15
|
+
* This module therefore knows no dialect of its own — `fjs/media/revision`
|
|
16
|
+
* exports its own {@link DialectEntry} (`revisionDialect`), and a caller passes
|
|
17
|
+
* the list it wants recognized. Any format following the `vnd.fjs.<name>`
|
|
18
|
+
* convention (see `fjs/media/revision/README.md`), in this repo or downstream,
|
|
19
|
+
* is registerable the same way.
|
|
20
|
+
*
|
|
15
21
|
* Detection is semantic, not syntactic: any JSON that satisfies a dialect's
|
|
16
22
|
* schema is recognized regardless of key order, whitespace, or any other
|
|
17
23
|
* serialization detail — there is no byte-level shortcut such as a
|
|
18
|
-
* `{"dialect":` prefix check.
|
|
24
|
+
* `{"dialect":` prefix check. The text is parsed **once**, and each entry's
|
|
25
|
+
* `match` runs on the same parsed value, so N dialects cost N schema walks
|
|
26
|
+
* rather than N parses.
|
|
27
|
+
*
|
|
28
|
+
* A reported `mime_type` is a claim about a blob's *shape*, not a promise that
|
|
29
|
+
* decoding it succeeds: how strict detection is, is the dialect's own call (see
|
|
30
|
+
* {@link dialectEntry}'s `extraValidate`). Never route a decode decision
|
|
31
|
+
* through this verdict — a caller that intends to decode calls its dialect's
|
|
32
|
+
* decoder, which stays the authority.
|
|
19
33
|
*
|
|
20
34
|
* This only classifies an already-buffered `Vec` (`detectVec`'s single-`Vec`
|
|
21
35
|
* form), because dialect validation needs the whole parsed value. A single
|
|
@@ -29,9 +43,77 @@
|
|
|
29
43
|
*/
|
|
30
44
|
import type { Vec } from '../types/bit_vec/module.f.ts';
|
|
31
45
|
import { type DetectMeta } from './type/module.f.ts';
|
|
46
|
+
import type { Struct } from '../types/rtti/module.f.ts';
|
|
47
|
+
import type { Ts, Unknown } from '../types/rtti/ts/module.f.ts';
|
|
48
|
+
/**
|
|
49
|
+
* One registered dialect: the name it tags itself with, and a predicate
|
|
50
|
+
* deciding whether an already-parsed value is one of its blobs.
|
|
51
|
+
*
|
|
52
|
+
* `match` takes rtti's `Unknown` — the encoding-neutral one, admitting
|
|
53
|
+
* `bigint` and `undefined` — not `fjs/media/json`'s JSON-only `Unknown`, so an
|
|
54
|
+
* entry stays usable by a future non-JSON detector over the same dialects.
|
|
55
|
+
*
|
|
56
|
+
* The type is deliberately not opaque: a caller may write the struct by hand.
|
|
57
|
+
* The list is that caller's own declaration of what it wants recognized,
|
|
58
|
+
* passed to its own `detect` call, so a fabricated entry mislabels only that
|
|
59
|
+
* caller's results — there is no trust boundary between a caller and entries it
|
|
60
|
+
* writes itself. The boundary that does exist, untrusted blob content, is on
|
|
61
|
+
* the other side of `match`.
|
|
62
|
+
*/
|
|
63
|
+
export type DialectEntry = {
|
|
64
|
+
readonly dialect: string;
|
|
65
|
+
readonly match: (_: Unknown) => boolean;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Registers a dialect for detection: its rtti schema, plus whatever rtti can't
|
|
69
|
+
* say about it.
|
|
70
|
+
*
|
|
71
|
+
* The dialect name is read out of the schema's own `dialect` member — that
|
|
72
|
+
* member is a string const in a dialect schema, which is what makes the schema
|
|
73
|
+
* self-discriminating, so a separate name alongside it would be a second copy
|
|
74
|
+
* that can disagree with the first. It follows that no registration can claim
|
|
75
|
+
* one dialect while validating another's blobs. The media type is likewise
|
|
76
|
+
* derived rather than supplied: `application/${dialect}+json`, the mechanical
|
|
77
|
+
* derivation `fjs/media/revision/README.md` documents.
|
|
78
|
+
*
|
|
79
|
+
* `extraValidate` runs *after* structural validation, on the dialect's own
|
|
80
|
+
* decoded type, and closes the gap rtti leaves — "this string is
|
|
81
|
+
* cbase32-decodable", "this number is a non-negative safe integer". It returns
|
|
82
|
+
* `boolean`, so it has no error channel and nothing to report but yes or no,
|
|
83
|
+
* and it never sees the raw bytes: parsing and the schema walk stay with the
|
|
84
|
+
* detector. Omitting it gets classification (what a blob claims to be and
|
|
85
|
+
* structurally looks like); supplying it gets detection as strict as the
|
|
86
|
+
* dialect's own decoder.
|
|
87
|
+
*
|
|
88
|
+
* **The name is neither grammar-checked nor allowlisted.** A schema saying
|
|
89
|
+
* `dialect: 'foo'` yields `application/foo+json`, and that is intended:
|
|
90
|
+
* `vnd.fjs.*` is this repo's convention for its *own* formats, not a constraint
|
|
91
|
+
* on what a caller may detect — another vendor's `vnd.*` blob, or a widely used
|
|
92
|
+
* name not under `vnd.` at all, is a legitimate thing to register. The name is
|
|
93
|
+
* not attacker-controlled either: it comes from a schema a programmer wrote and
|
|
94
|
+
* passed here, never from the blob being classified, so no untrusted string
|
|
95
|
+
* reaches `mime_type` along this path. (A detector that read the name *out of*
|
|
96
|
+
* a blob would need the RFC 6838 grammar check and an allowlist; this one does
|
|
97
|
+
* not.) Registering a name that is not a valid RFC 6838 restricted-name yields
|
|
98
|
+
* a malformed media type in the registrant's own results, and nowhere else.
|
|
99
|
+
*
|
|
100
|
+
* The constraint is `Struct` — every member must be a real rtti `Type`, which
|
|
101
|
+
* is what rejects a member like `() => 42` at compile time (rtti would read it
|
|
102
|
+
* as a thunk and `match` would *throw* on the first blob rather than return
|
|
103
|
+
* `false`). TypeScript cannot also require a direct string `dialect` member
|
|
104
|
+
* under that constraint, so that half is asserted here instead: loudly, once,
|
|
105
|
+
* when the entry is constructed. A thunk-form `dialect`
|
|
106
|
+
* (`() => ['const', 'x']`) is a perfectly valid rtti schema, it just is not
|
|
107
|
+
* registerable — write the string directly, as `revisionSchema` does.
|
|
108
|
+
*/
|
|
109
|
+
export declare const dialectEntry: <T extends Struct>(type: T, extraValidate?: (_: Ts<T>) => boolean) => DialectEntry;
|
|
32
110
|
/**
|
|
33
|
-
* Classifies a whole buffered `Vec`, the same
|
|
34
|
-
* type }` shape as `fjs/media/type`
|
|
35
|
-
* recognized ahead of the plain
|
|
111
|
+
* Classifies a whole buffered `Vec` against `dialects`, returning the same
|
|
112
|
+
* three-way `{ length, mime_type, type }` shape as `fjs/media/type`
|
|
113
|
+
* `detectVec`, but with dialect-tagged JSON recognized ahead of the plain
|
|
114
|
+
* `text/plain` fallback. The first entry whose `match` accepts the parsed value
|
|
115
|
+
* wins; entries that overlap are the registrant's own business, since matching
|
|
116
|
+
* `dialect` as an exact literal already makes structural validation reject
|
|
117
|
+
* every other dialect's blob.
|
|
36
118
|
*/
|
|
37
|
-
export declare const detect: (bytes: Vec) => DetectMeta;
|
|
119
|
+
export declare const detect: (dialects: readonly DialectEntry[]) => (bytes: Vec) => DetectMeta;
|