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
@@ -526,5 +526,31 @@ export const proof = {
526
526
  const result = stringifyDjsModule(obj[1]);
527
527
  assertEq(result, '[[],[null]]');
528
528
  },
529
+ ],
530
+ // Regression: closing a container popped the parser stack with `drop(1)`,
531
+ // which is lazy, so every closed container left one unforced thunk wrapping
532
+ // the stack. The chain was forced only at the end, costing a call-stack
533
+ // frame per container and overflowing at roughly 5000 of them — whether
534
+ // nested or flat siblings — while primitives were unbounded. `fjs/media/json`
535
+ // carried the same defect and was fixed in the same way.
536
+ containerStackCost: [
537
+ () => {
538
+ const [tag, value] = parseFromTokens(tokenizeString(`export default [${Array(20000).fill('{}').join(',')}]`));
539
+ assert(tag === 'ok', tag);
540
+ assertEq(value[1].length, 1);
541
+ },
542
+ () => {
543
+ const [tag] = parseFromTokens(tokenizeString(`export default [${Array(20000).fill('[]').join(',')}]`));
544
+ assert(tag === 'ok', tag);
545
+ },
546
+ () => {
547
+ const [tag] = parseFromTokens(tokenizeString('export default ' + '['.repeat(20000) + ']'.repeat(20000)));
548
+ assert(tag === 'ok', tag);
549
+ },
550
+ () => {
551
+ // primitives never touched the stack — the baseline that always passed
552
+ const [tag] = parseFromTokens(tokenizeString(`export default [${Array.from({ length: 20000 }, (_, i) => i).join(',')}]`));
553
+ assert(tag === 'ok', tag);
554
+ },
529
555
  ]
530
556
  };
@@ -1167,12 +1167,19 @@ export const proof = {
1167
1167
  () => {
1168
1168
  // many distinct short tokens, well past the ~1000-1500 token crash threshold
1169
1169
  // the old whole-file recursive match hit for this shape
1170
- const src = Array.from({ length: 3000 }, (_, i) => `a${i % 10}`).join(' ');
1170
+ const ids = Array.from({ length: 3000 }, (_, i) => `a${i % 10}`);
1171
+ const src = ids.join(' ');
1171
1172
  const result = tokenizeString(src);
1172
1173
  assert(result !== 'error', result);
1173
- const parsed = JSON.parse(result);
1174
- // 3000 ids + 2999 separating ws + a trailing eof token
1175
- assertEq(parsed.length, 3000 + 2999 + 1);
1174
+ // `tokenizeString` runs its own pipeline (`getTokensFromAstRule` +
1175
+ // filter/scan + `stringify`), not `tokenizeJs`'s, so only its dump
1176
+ // covers it. Compared whole rather than counted: at this size the
1177
+ // count was the only thing the old JSON round-trip could check,
1178
+ // while the reconstructed literal also pins every token's content.
1179
+ assertEq(result, `[${ids.map(v => `{"kind":"id","value":"${v}"}`).join(',{"kind":"ws"},')},{"kind":"eof"}]`);
1180
+ // The token list must agree with the dump: 3000 ids + 2999
1181
+ // separating ws + a trailing eof token.
1182
+ assertEq(toArray(tokenizeJs(stringToList(src))('a.js')).length, 3000 + 2999 + 1);
1176
1183
  },
1177
1184
  () => {
1178
1185
  // same many-short-tokens shape through the metadata-aware entry point
@@ -379,6 +379,26 @@ export type MatchResult<O extends Operation, T, R> = readonly ['done', T] | read
379
379
  * with the continuation. The one world-specific step — `await` for async
380
380
  * runners, state threading for sync ones — is left to the caller, so every
381
381
  * interpreter loop is this skeleton plus a single eliminator line.
382
+ *
383
+ * **The handler is looked up with `at`, never with `map[command]`.**
384
+ * `OperationMap<O, R>` pins `command` to `O[0]` at the type level, but a `Do`
385
+ * node's `command` is runtime data — it can reach an interpreter from a decoded
386
+ * payload or a deserialized continuation, where no type ever constrained it.
387
+ * `map` is an ordinary object, so a plain index read resolves an inherited name
388
+ * (`'constructor'`, `'toString'`, `'hasOwnProperty'`) to the `Object.prototype`
389
+ * member instead of `undefined`, and the line below would then call it with the
390
+ * node's payload: a value the type system promised was `(...payload) => R` turns
391
+ * out to be an arbitrary inherited function, chosen by the same input that
392
+ * supplies its arguments. `at` reads through `getOwnPropertyDescriptor`, which
393
+ * only ever sees own properties, so such a command yields `null` and never a
394
+ * callable.
395
+ *
396
+ * A `null` handler is an invariant violation, not an outcome: every `O1 extends
397
+ * O` the signature admits has its command in `map`, so reaching it means the
398
+ * node's `command` was never the `O1[0]` it claimed to be. It therefore throws
399
+ * (`assert`) rather than widening {@link MatchResult} with a variant no
400
+ * type-correct caller could ever observe — a runner cannot resume a command it
401
+ * has no handler for, so there is nothing for a recovery branch to do.
382
402
  */
383
403
  export declare const match: <O extends Operation, R>(map: OperationMap<O, R>) => <O1 extends O, T>(e: Effect<O1, T>) => MatchResult<O1, T, R>;
384
404
  export type ToAsyncOperationMap<O extends Operation> = {
@@ -94,7 +94,9 @@
94
94
  *
95
95
  * @module
96
96
  */
97
+ import { assert } from '../asserts/module.f.js';
97
98
  import { fold } from '../types/list/module.f.js';
99
+ import { at } from '../types/object/module.f.js';
98
100
  export const pure = (v) => () => v;
99
101
  /**
100
102
  * Composes effects: run `e`, then continue with `f` applied to its result.
@@ -278,11 +280,33 @@ export const runPure = (e) => typeof e === 'function' ? [e()] : [];
278
280
  * with the continuation. The one world-specific step — `await` for async
279
281
  * runners, state threading for sync ones — is left to the caller, so every
280
282
  * interpreter loop is this skeleton plus a single eliminator line.
283
+ *
284
+ * **The handler is looked up with `at`, never with `map[command]`.**
285
+ * `OperationMap<O, R>` pins `command` to `O[0]` at the type level, but a `Do`
286
+ * node's `command` is runtime data — it can reach an interpreter from a decoded
287
+ * payload or a deserialized continuation, where no type ever constrained it.
288
+ * `map` is an ordinary object, so a plain index read resolves an inherited name
289
+ * (`'constructor'`, `'toString'`, `'hasOwnProperty'`) to the `Object.prototype`
290
+ * member instead of `undefined`, and the line below would then call it with the
291
+ * node's payload: a value the type system promised was `(...payload) => R` turns
292
+ * out to be an arbitrary inherited function, chosen by the same input that
293
+ * supplies its arguments. `at` reads through `getOwnPropertyDescriptor`, which
294
+ * only ever sees own properties, so such a command yields `null` and never a
295
+ * callable.
296
+ *
297
+ * A `null` handler is an invariant violation, not an outcome: every `O1 extends
298
+ * O` the signature admits has its command in `map`, so reaching it means the
299
+ * node's `command` was never the `O1[0]` it claimed to be. It therefore throws
300
+ * (`assert`) rather than widening {@link MatchResult} with a variant no
301
+ * type-correct caller could ever observe — a runner cannot resume a command it
302
+ * has no handler for, so there is nothing for a recovery branch to do.
281
303
  */
282
304
  export const match = (map) => (e) => {
283
305
  if (typeof e === 'function') {
284
306
  return ['done', e()];
285
307
  }
286
308
  const { command, payload, continuation } = e;
287
- return ['cont', map[command](...payload), continuation];
309
+ const handler = at(command)(map);
310
+ assert(handler !== null, command);
311
+ return ['cont', handler(...payload), continuation];
288
312
  };
@@ -31,6 +31,11 @@ export declare const proof: {
31
31
  match: {
32
32
  done: () => void;
33
33
  cont: () => void;
34
+ ownCommand: () => void;
35
+ throw: {
36
+ constructorCommand: () => void;
37
+ toStringCommand: () => void;
38
+ };
34
39
  };
35
40
  step: {
36
41
  pure: () => void;
@@ -15,6 +15,7 @@ export const assertPure = (e, expected) => {
15
15
  assertEq(o[0], expected);
16
16
  };
17
17
  const next = match({ add: (a, b) => a + b });
18
+ const anyNext = match({ add: a => a + 1 });
18
19
  export const proof = {
19
20
  foldStep: {
20
21
  empty: () => {
@@ -103,6 +104,27 @@ export const proof = {
103
104
  assert(r2[0] === 'done', r2);
104
105
  assertEq(r2[1], 5);
105
106
  },
107
+ ownCommand: () => {
108
+ // The same map the two cases below dispatch against: an own
109
+ // property still resolves, so what they prove is refusal of
110
+ // inherited names, not a map that dispatches nothing.
111
+ const r = anyNext(do_('add')(41));
112
+ assert(r[0] === 'cont', r);
113
+ assertEq(r[1], 42);
114
+ },
115
+ // A `command` naming an `Object.prototype` member must not dispatch to
116
+ // the inherited value. `map['constructor']` is `Object` and
117
+ // `map['toString']` is `Function.prototype.toString` — both callable,
118
+ // neither a handler — so a plain index read would call one of them with
119
+ // the node's payload instead of throwing.
120
+ throw: {
121
+ constructorCommand: () => {
122
+ anyNext(do_('constructor')(1));
123
+ },
124
+ toStringCommand: () => {
125
+ anyNext(do_('toString')(1));
126
+ },
127
+ },
106
128
  },
107
129
  step: {
108
130
  pure: () => {
@@ -5,10 +5,27 @@ import { assert, assertEq } from '../asserts/module.f.js';
5
5
  import { testAll, defaultReporter, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, registerModule, parseTestSet, defaultTest, } from './module.f.js';
6
6
  import { run as mockRun } from '../effects/mock/module.f.js';
7
7
  import { shouldLoad } from '../dev/module.f.js';
8
- const writeEvent = (event) => log(JSON.stringify(event));
9
- const parseEvents = (stdout) => stdout === '' ? [] : stdout.trimEnd().split('\n').map(line => JSON.parse(line));
8
+ import { parse as parseJson } from '../media/json/module.f.js';
9
+ import { array, number as rttiNumber, or, string as rttiString } from '../types/rtti/module.f.js';
10
+ import { parse as rttiParse } from '../types/rtti/parse/module.f.js';
11
+ import { unwrap } from '../types/result/module.f.js';
12
+ /**
13
+ * The mock reporter's stdout lines. A schema rather than a hand-written type:
14
+ * the events round-trip through JSON, and this is what turns a line back into
15
+ * a typed value without an `as` cast — a shape change fails at the parse
16
+ * instead of silently at an assertion.
17
+ *
18
+ * `Reporter.result` also receives the test's `SandboxResult`, which is not
19
+ * written: no assertion here reads it, and its `undefined` payloads have no
20
+ * JSON representation to round-trip through anyway.
21
+ */
22
+ const event = or(['result', rttiString, array(or(rttiString, null))], ['summary', rttiNumber, rttiNumber, rttiNumber]);
23
+ const parseEvent = rttiParse(event);
24
+ const writeEvent = (e) => log(JSON.stringify(e));
25
+ const parseEvents = (stdout) => stdout === '' ? [] : stdout.trimEnd().split('\n')
26
+ .map(line => unwrap(parseEvent(unwrap(parseJson(line)))));
10
27
  const makeReporter = () => ({
11
- result: (file, path, r, _throws) => writeEvent(['result', file, [...path], r]),
28
+ result: (file, path, _r, _throws) => writeEvent(['result', file, [...path]]),
12
29
  summary: (pass, fail, time) => writeEvent(['summary', pass, fail, time]),
13
30
  test: defaultTest,
14
31
  });
@@ -357,6 +357,13 @@ export const proof = {
357
357
  throw result;
358
358
  }
359
359
  },
360
+ () => {
361
+ // a second "." after the fraction has started (covers fullStopToToken's default branch)
362
+ const result = stringify(tokenizeString('1..'));
363
+ if (result !== '[{"kind":"error","message":"invalid number"},{"kind":"eof"}]') {
364
+ throw result;
365
+ }
366
+ },
360
367
  () => {
361
368
  const result = stringify(tokenizeString('0e1'));
362
369
  if (result !== '[{"bf":[0n,1],"kind":"number","value":"0e1"},{"kind":"eof"}]') {
@@ -109,7 +109,8 @@ import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.js';
109
109
  import { decode as base64Decode, encode as base64Encode } from '../../basen/base64/module.f.js';
110
110
  import { tryUtf8 } from '../../text/module.f.js';
111
111
  import { detectStream } from '../../media/type/module.f.js';
112
- import { detect as detectDialect } from '../../media/module.f.js';
112
+ import { detect } from '../../media/module.f.js';
113
+ import { revisionDialect } from '../../media/revision/module.f.js';
113
114
  import { maxLengthBytes } from '../../types/bit_vec/module.f.js';
114
115
  import { ok } from '../../types/result/module.f.js';
115
116
  import { toolEntry, errorResult, okResult, } from '../../protocol/mcp/module.f.js';
@@ -134,6 +135,8 @@ export const casGetArgs = {
134
135
  export const casListArgs = {};
135
136
  // ── Tool registry ──────────────────────────────────────────────────────────────
136
137
  const toJson = stringify(identity);
138
+ /** The dialect-aware classifier, bound to the dialects this server recognizes. */
139
+ const detectDialect = detect([revisionDialect]);
137
140
  /**
138
141
  * Registry of all CAS tools, bound to `home` and the Evo cache at
139
142
  * `cacheKey`. `cas_add` is the only tool that writes, so it is the only one
@@ -2,8 +2,13 @@ import { type Operation } from '../../effects/module.f.ts';
2
2
  import { type MemOp } from '../../effects/memory/module.f.ts';
3
3
  import { type ToolEntry } from '../../protocol/mcp/module.f.ts';
4
4
  import { type Evo } from '../../cas/evo/module.f.ts';
5
- /** Arguments for `evo_list`: none. */
6
- export declare const evoListArgs: {};
5
+ /**
6
+ * Arguments for `evo_list`: an optional status filter, forwarded unchanged to
7
+ * `Evo.list` — omitted lists the active subjects, `true` the archived ones.
8
+ */
9
+ export declare const evoListArgs: {
10
+ readonly archived: import("../../types/rtti/module.f.ts").Or<readonly [true, undefined]>;
11
+ };
7
12
  /** Arguments for `evo_head`: the subject whose current heads are requested. */
8
13
  export declare const evoHeadArgs: {
9
14
  readonly subject: import("../../types/rtti/module.f.ts").String;
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * | Tool | args | action | result |
10
10
  * |----------------|-----------------------------------------------|-------------------|--------------------------------------|
11
- * | `evo_list` | `{}` | `e.list()` | subjects, as a JSON array of strings |
11
+ * | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
12
12
  * | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
13
13
  * | `evo_revision` | `{ hash }` | `e.revision(...)` | the revision, as JSON `RevisionData` |
14
14
  * | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
@@ -51,8 +51,13 @@ import { stringify } from '../../media/json/module.f.js';
51
51
  import { identity } from '../../types/function/module.f.js';
52
52
  import {} from '../../cas/evo/module.f.js';
53
53
  // ── Argument schemas (declared once, used for both inputSchema and validate) ─────
54
- /** Arguments for `evo_list`: none. */
55
- export const evoListArgs = {};
54
+ /**
55
+ * Arguments for `evo_list`: an optional status filter, forwarded unchanged to
56
+ * `Evo.list` — omitted lists the active subjects, `true` the archived ones.
57
+ */
58
+ export const evoListArgs = {
59
+ archived: option(true),
60
+ };
56
61
  /** Arguments for `evo_head`: the subject whose current heads are requested. */
57
62
  export const evoHeadArgs = {
58
63
  subject: string,
@@ -77,12 +82,12 @@ export const evoAddArgs = {
77
82
  const toJson = stringify(identity);
78
83
  /** Registry of all Evo tools, bound to an `Evo<O>`. */
79
84
  export const evoToolRegistry = (e) => [
80
- toolEntry('evo_list', 'List all subjects with at least one stored revision, as a JSON array of strings.', evoListArgs,
85
+ toolEntry('evo_list', 'List subjects, as a JSON array of strings. By default only the active ones: a subject is active while at least one of its current heads is not archived. Pass `archived: true` to list the archived subjects instead — those with at least one current head, all of them archived. A subject with no current heads is in neither list.', evoListArgs,
81
86
  // Subjects are arbitrary caller-supplied strings (unlike hashes, not
82
87
  // constrained to a newline-free alphabet), so a `join('\n')` line
83
88
  // format could not represent an empty subject or one containing a
84
89
  // newline without ambiguity — JSON encoding can.
85
- () => step(e.list(), subjects => pure(okResult(toJson(subjects))))),
90
+ ({ archived }) => step(e.list(archived), subjects => pure(okResult(toJson(subjects))))),
86
91
  toolEntry('evo_head', 'List the current head hashes (cBase32) of a subject, one per line. Empty when the subject is unknown.', evoHeadArgs, ({ subject }) => step(e.head(subject), heads => pure(okResult(heads.join('\n'))))),
87
92
  toolEntry('evo_revision', 'Read one revision by hash, as JSON: `{ subject, parents, snapshot, generation, archived? }`. `parents[0]` is the mainline parent and every further entry is a merged-in branch; `parents` and `snapshot` come back in their canonical cBase32 spelling, so they compare directly against `evo_head` output. Errors when the hash is not cBase32, is not present in the store, could not be read, or does not hold a `vnd.fjs.revision` blob — use `cas_get` for raw bytes of non-revision content.', evoRevisionArgs,
88
93
  // The revision goes out as JSON in a text content item, like
@@ -2,6 +2,7 @@ export declare const proof: {
2
2
  toolNamesMatchTheDesign: () => void;
3
3
  evoListReflectsTheCache: () => void;
4
4
  evoListEncodesArbitrarySubjectsAsJson: () => void;
5
+ evoListForwardsTheArchivedFilter: () => void;
5
6
  evoHeadReflectsTheCache: () => void;
6
7
  evoHeadMissingSubjectIsInvalidArguments: () => void;
7
8
  evoRevisionReturnsRevisionJson: () => void;
@@ -6,7 +6,12 @@ import { vec8 } from '../../types/bit_vec/module.f.js';
6
6
  import { vecToCBase32 } from '../../basen/cbase32/module.f.js';
7
7
  import { initEvo, evo } from '../../cas/evo/module.f.js';
8
8
  import { evoToolRegistry } from './module.f.js';
9
+ import { parse as parseJson } from '../../media/json/module.f.js';
10
+ import { array, string as rttiString } from '../../types/rtti/module.f.js';
11
+ import { parse as rttiParse } from '../../types/rtti/parse/module.f.js';
12
+ import { unwrap } from '../../types/result/module.f.js';
9
13
  const home = '/home/user';
14
+ const parseSubjects = rttiParse(array(rttiString));
10
15
  const findEntry = (registry, name) => {
11
16
  const entry = registry.find(e => e.name === name);
12
17
  assert(entry !== undefined, ['missing tool entry', name]);
@@ -52,11 +57,27 @@ export const proof = {
52
57
  const entry = findEntry(evoToolRegistry(e), 'evo_list');
53
58
  const [, result] = virtual(state2)(entry.handle({}));
54
59
  assert(!result.isError);
55
- const subjects = JSON.parse(textOf(result));
60
+ const subjects = unwrap(parseSubjects(unwrap(parseJson(textOf(result)))));
56
61
  assertEq(subjects.length, 2);
57
62
  assert(subjects.includes('line one\nline two'), ['unexpected subjects', subjects]);
58
63
  assert(subjects.includes(''), ['unexpected subjects', subjects]);
59
64
  },
65
+ // The `archived` argument is a pass-through to `Evo.list`'s status filter:
66
+ // omitted lists the active subjects, `true` the archived ones.
67
+ evoListForwardsTheArchivedFilter: () => {
68
+ const c = fileCas(sha256)(home);
69
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
70
+ const e = evo(c)(cacheKey);
71
+ const [state1, add] = virtual(state0)(e.add({ parents: [], subject: 'gone', snapshot: vecToCBase32(vec8(0x2cn)), archived: true }));
72
+ assert(add[0] === 'ok', ['expected add ok', add]);
73
+ const entry = findEntry(evoToolRegistry(e), 'evo_list');
74
+ const [state2, active] = virtual(state1)(entry.handle({}));
75
+ assert(!active.isError);
76
+ assertEq(textOf(active), '[]');
77
+ const [, archived] = virtual(state2)(entry.handle({ archived: true }));
78
+ assert(!archived.isError);
79
+ assertEq(textOf(archived), '["gone"]');
80
+ },
60
81
  evoHeadReflectsTheCache: () => {
61
82
  const c = fileCas(sha256)(home);
62
83
  const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
@@ -14,7 +14,7 @@
14
14
  * | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
15
15
  * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
16
16
  * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
17
- * | `evo_list` | `{}` | `e.list()` | subjects, one per line |
17
+ * | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
18
18
  * | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
19
19
  * | `evo_revision` | `{ hash }` | `e.revision(...)`| the revision, as JSON |
20
20
  * | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
@@ -14,7 +14,7 @@
14
14
  * | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
15
15
  * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
16
16
  * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
17
- * | `evo_list` | `{}` | `e.list()` | subjects, one per line |
17
+ * | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
18
18
  * | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
19
19
  * | `evo_revision` | `{ hash }` | `e.revision(...)`| the revision, as JSON |
20
20
  * | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
@@ -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 => JSON.parse(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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(getResp));
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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(metaResp));
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 = JSON.parse(textOf(getResp));
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
- export declare const parse: (value: string) => Unknown;
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 {};