functionalscript 0.40.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.
Files changed (61) hide show
  1. package/fjs/cas/evo/module.f.d.ts +27 -7
  2. package/fjs/cas/evo/module.f.js +42 -9
  3. package/fjs/cas/evo/proof.f.d.ts +4 -0
  4. package/fjs/cas/evo/proof.f.js +81 -0
  5. package/fjs/ci/config/module.f.d.ts +5 -5
  6. package/fjs/ci/config/module.f.js +5 -5
  7. package/fjs/ci/deno/module.f.d.ts +8 -0
  8. package/fjs/ci/deno/module.f.js +9 -1
  9. package/fjs/ci/deno/proof.f.d.ts +5 -0
  10. package/fjs/ci/deno/proof.f.js +26 -0
  11. package/fjs/ci/node/proof.f.d.ts +3 -0
  12. package/fjs/ci/node/proof.f.js +17 -0
  13. package/fjs/ci/proof.f.js +3 -2
  14. package/fjs/dev/module.f.d.ts +15 -4
  15. package/fjs/dev/module.f.js +38 -7
  16. package/fjs/dev/package_json/module.f.js +7 -4
  17. package/fjs/dev/proof.f.js +10 -1
  18. package/fjs/djs/parser/module.f.js +13 -3
  19. package/fjs/djs/parser/proof.f.d.ts +1 -0
  20. package/fjs/djs/parser/proof.f.js +26 -0
  21. package/fjs/djs/tokenizer/proof.f.js +11 -4
  22. package/fjs/effects/module.f.d.ts +20 -0
  23. package/fjs/effects/module.f.js +25 -1
  24. package/fjs/effects/proof.f.d.ts +5 -0
  25. package/fjs/effects/proof.f.js +22 -0
  26. package/fjs/emergent_testing/proof.f.js +20 -3
  27. package/fjs/js/tokenizer/proof.f.js +7 -0
  28. package/fjs/mcp/cas/module.f.js +4 -1
  29. package/fjs/mcp/evo/module.f.d.ts +7 -2
  30. package/fjs/mcp/evo/module.f.js +10 -5
  31. package/fjs/mcp/evo/proof.f.d.ts +1 -0
  32. package/fjs/mcp/evo/proof.f.js +22 -1
  33. package/fjs/mcp/module.f.d.ts +1 -1
  34. package/fjs/mcp/module.f.js +1 -1
  35. package/fjs/mcp/proof.f.js +37 -18
  36. package/fjs/media/json/module.f.d.ts +15 -1
  37. package/fjs/media/json/module.f.js +17 -1
  38. package/fjs/media/json/parser/module.f.js +13 -3
  39. package/fjs/media/json/parser/proof.f.d.ts +1 -0
  40. package/fjs/media/json/parser/proof.f.js +37 -1
  41. package/fjs/media/json/proof.f.d.ts +4 -0
  42. package/fjs/media/json/proof.f.js +14 -2
  43. package/fjs/media/json/serializer/module.f.d.ts +4 -0
  44. package/fjs/media/json/serializer/module.f.js +39 -2
  45. package/fjs/media/json/serializer/proof.f.js +32 -8
  46. package/fjs/media/module.f.d.ts +92 -10
  47. package/fjs/media/module.f.js +73 -8
  48. package/fjs/media/proof.f.d.ts +10 -0
  49. package/fjs/media/proof.f.js +79 -10
  50. package/fjs/media/revision/module.f.d.ts +9 -1
  51. package/fjs/media/revision/module.f.js +14 -5
  52. package/fjs/protocol/mcp/stdio/module.f.js +2 -5
  53. package/fjs/text/utf16/module.f.d.ts +24 -0
  54. package/fjs/text/utf16/module.f.js +24 -0
  55. package/fjs/text/utf16/proof.f.d.ts +1 -0
  56. package/fjs/text/utf16/proof.f.js +10 -1
  57. package/fjs/types/rtti/validate/proof.f.d.ts +2 -0
  58. package/fjs/types/rtti/validate/proof.f.js +58 -0
  59. package/nanvm-lib/tests/proof.f.d.ts +9 -0
  60. package/nanvm-lib/tests/proof.f.js +58 -7
  61. package/package.json +2 -2
@@ -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
- export const parse = JSON.parse;
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, first, drop, toArray, concat } from '../../../types/list/module.f.js';
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 = { status: '', top: first(null)(state.stack), stack: drop(1)(state.stack) };
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 = { status: '', top: first(null)(state.stack), stack: drop(1)(state.stack) };
57
+ const newState = popStack(state.stack);
48
58
  return pushValue(newState)(obj);
49
59
  };
50
60
  const tokenToValue = token => {
@@ -1,4 +1,5 @@
1
1
  export declare const proof: {
2
2
  valid: (() => void)[];
3
3
  invalid: (() => void)[];
4
+ siblingContainers: (() => void)[];
4
5
  };
@@ -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
  };
@@ -5,4 +5,8 @@ export declare const proof: {
5
5
  identity: () => void;
6
6
  }[];
7
7
  undefined: () => void;
8
+ parse: {
9
+ ok: () => void;
10
+ error: () => void;
11
+ };
8
12
  };
@@ -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 => [jsonStringify(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
- const result = JSON.stringify(toArray(stringSerialize('abc')));
43
- assertEq(result, '["\\"abc\\""]');
44
- },
45
- () => {
46
- const result = JSON.stringify(toArray(stringSerialize('123')));
47
- assertEq(result, '["\\"123\\""]');
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
  () => {
@@ -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), try
9
- * parsing it as JSON and validating it against a known dialect's rtti schema
10
- * (currently just `vnd.fjs.revision` see `fjs/media/revision`). A match
11
- * reports the dialect's derived media type; anything that isn't valid JSON,
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 three-way `{ length, mime_type,
34
- * type }` shape as `fjs/media/type` `detectVec`, but with dialect-tagged JSON
35
- * recognized ahead of the plain `text/plain` fallback.
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;
@@ -1,13 +1,72 @@
1
1
  import { fromVec } from '../text/utf8/module.f.js';
2
2
  import { detectVec } from './type/module.f.js';
3
- import { decodeText as decodeRevisionText, mediaType as revisionMediaType } from './revision/module.f.js';
4
- import { assertNotNullish } from '../asserts/module.f.js';
3
+ import { parse } from './json/module.f.js';
4
+ import { assert, assertNotNullish } from '../asserts/module.f.js';
5
+ import { validate } from '../types/rtti/validate/module.f.js';
6
+ /** The default refinement: structural validation alone decides the match. */
7
+ const always = () => true;
8
+ /** Structural validation followed by the dialect's own refinement. */
9
+ const matchWith = (v) => (extraValidate) => (u) => {
10
+ const [tag, value] = v(u);
11
+ return tag === 'ok' && extraValidate(value);
12
+ };
13
+ /**
14
+ * Registers a dialect for detection: its rtti schema, plus whatever rtti can't
15
+ * say about it.
16
+ *
17
+ * The dialect name is read out of the schema's own `dialect` member — that
18
+ * member is a string const in a dialect schema, which is what makes the schema
19
+ * self-discriminating, so a separate name alongside it would be a second copy
20
+ * that can disagree with the first. It follows that no registration can claim
21
+ * one dialect while validating another's blobs. The media type is likewise
22
+ * derived rather than supplied: `application/${dialect}+json`, the mechanical
23
+ * derivation `fjs/media/revision/README.md` documents.
24
+ *
25
+ * `extraValidate` runs *after* structural validation, on the dialect's own
26
+ * decoded type, and closes the gap rtti leaves — "this string is
27
+ * cbase32-decodable", "this number is a non-negative safe integer". It returns
28
+ * `boolean`, so it has no error channel and nothing to report but yes or no,
29
+ * and it never sees the raw bytes: parsing and the schema walk stay with the
30
+ * detector. Omitting it gets classification (what a blob claims to be and
31
+ * structurally looks like); supplying it gets detection as strict as the
32
+ * dialect's own decoder.
33
+ *
34
+ * **The name is neither grammar-checked nor allowlisted.** A schema saying
35
+ * `dialect: 'foo'` yields `application/foo+json`, and that is intended:
36
+ * `vnd.fjs.*` is this repo's convention for its *own* formats, not a constraint
37
+ * on what a caller may detect — another vendor's `vnd.*` blob, or a widely used
38
+ * name not under `vnd.` at all, is a legitimate thing to register. The name is
39
+ * not attacker-controlled either: it comes from a schema a programmer wrote and
40
+ * passed here, never from the blob being classified, so no untrusted string
41
+ * reaches `mime_type` along this path. (A detector that read the name *out of*
42
+ * a blob would need the RFC 6838 grammar check and an allowlist; this one does
43
+ * not.) Registering a name that is not a valid RFC 6838 restricted-name yields
44
+ * a malformed media type in the registrant's own results, and nowhere else.
45
+ *
46
+ * The constraint is `Struct` — every member must be a real rtti `Type`, which
47
+ * is what rejects a member like `() => 42` at compile time (rtti would read it
48
+ * as a thunk and `match` would *throw* on the first blob rather than return
49
+ * `false`). TypeScript cannot also require a direct string `dialect` member
50
+ * under that constraint, so that half is asserted here instead: loudly, once,
51
+ * when the entry is constructed. A thunk-form `dialect`
52
+ * (`() => ['const', 'x']`) is a perfectly valid rtti schema, it just is not
53
+ * registerable — write the string directly, as `revisionSchema` does.
54
+ */
55
+ export const dialectEntry = (type, extraValidate = always) => {
56
+ const { dialect } = type;
57
+ assert(typeof dialect === 'string', 'dialectEntry: schema has no direct string `dialect` member');
58
+ return { dialect, match: matchWith(validate(type))(extraValidate) };
59
+ };
5
60
  /**
6
- * Classifies a whole buffered `Vec`, the same three-way `{ length, mime_type,
7
- * type }` shape as `fjs/media/type` `detectVec`, but with dialect-tagged JSON
8
- * recognized ahead of the plain `text/plain` fallback.
61
+ * Classifies a whole buffered `Vec` against `dialects`, returning the same
62
+ * three-way `{ length, mime_type, type }` shape as `fjs/media/type`
63
+ * `detectVec`, but with dialect-tagged JSON recognized ahead of the plain
64
+ * `text/plain` fallback. The first entry whose `match` accepts the parsed value
65
+ * wins; entries that overlap are the registrant's own business, since matching
66
+ * `dialect` as an exact literal already makes structural validation reject
67
+ * every other dialect's blob.
9
68
  */
10
- export const detect = (bytes) => {
69
+ export const detect = (dialects) => (bytes) => {
11
70
  const base = detectVec(bytes);
12
71
  // Only whole-blob-valid UTF-8 text can possibly be JSON; a magic-byte hit
13
72
  // or binary fallback is never a dialect match.
@@ -18,6 +77,12 @@ export const detect = (bytes) => {
18
77
  // whole-blob-valid UTF-8 — the same two conditions `fromVec` checks, via the
19
78
  // same decoder — so `fromVec` cannot return `null` here.
20
79
  const text = assertNotNullish(fromVec(bytes), 'detect: type text implies fromVec succeeds');
21
- const [tag] = decodeRevisionText(text);
22
- return tag === 'ok' ? { ...base, mime_type: revisionMediaType } : base;
80
+ const [tag, value] = parse(text);
81
+ if (tag === 'error') {
82
+ return base;
83
+ }
84
+ const matched = dialects.find(({ match }) => match(value));
85
+ return matched === undefined
86
+ ? base
87
+ : { ...base, mime_type: `application/${matched.dialect}+json` };
23
88
  };
@@ -1,9 +1,19 @@
1
+ import { type DialectEntry } from './module.f.ts';
1
2
  export declare const proof: {
2
3
  validRevision: () => void;
3
4
  keyOrderIndependent: () => void;
4
5
  invalidRevisionFallsThrough: () => void;
6
+ nonHashSnapshotFallsThrough: () => void;
7
+ secondDialect: () => void;
8
+ nonVndDialectName: () => void;
9
+ firstMatchWins: () => void;
10
+ noDialects: () => void;
5
11
  ordinaryJsonFallsThrough: () => void;
6
12
  plainTextFallsThrough: () => void;
7
13
  binaryFallsThrough: () => void;
8
14
  largeNonJsonWithinBound: () => void;
15
+ throw: {
16
+ thunkDialectMember: () => DialectEntry;
17
+ noDialectMember: () => DialectEntry;
18
+ };
9
19
  };