functionalscript 0.39.0 → 0.41.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 (95) hide show
  1. package/README.md +1 -1
  2. package/fjs/bnf/descent/module.f.d.ts +43 -2
  3. package/fjs/bnf/descent/module.f.js +37 -12
  4. package/fjs/bnf/descent/proof.f.d.ts +1 -0
  5. package/fjs/bnf/descent/proof.f.js +73 -32
  6. package/fjs/bnf/ll1/module.f.js +2 -2
  7. package/fjs/cas/evo/module.f.d.ts +28 -8
  8. package/fjs/cas/evo/module.f.js +43 -10
  9. package/fjs/cas/evo/proof.f.d.ts +4 -0
  10. package/fjs/cas/evo/proof.f.js +82 -1
  11. package/fjs/ci/config/module.f.d.ts +10 -7
  12. package/fjs/ci/config/module.f.js +23 -8
  13. package/fjs/ci/module.f.js +12 -5
  14. package/fjs/ci/nix/module.f.d.ts +61 -0
  15. package/fjs/ci/nix/module.f.js +92 -0
  16. package/fjs/ci/nix/proof.f.d.ts +23 -0
  17. package/fjs/ci/nix/proof.f.js +109 -0
  18. package/fjs/ci/node/module.f.d.ts +23 -1
  19. package/fjs/ci/node/module.f.js +47 -3
  20. package/fjs/ci/node/proof.f.d.ts +3 -0
  21. package/fjs/ci/node/proof.f.js +17 -0
  22. package/fjs/ci/proof.f.d.ts +2 -0
  23. package/fjs/ci/proof.f.js +47 -9
  24. package/fjs/dev/module.f.d.ts +1 -0
  25. package/fjs/dev/module.f.js +13 -2
  26. package/fjs/dev/update/module.f.d.ts +11 -0
  27. package/fjs/dev/update/module.f.js +20 -0
  28. package/fjs/dev/update/proof.f.d.ts +7 -0
  29. package/fjs/dev/update/proof.f.js +35 -0
  30. package/fjs/djs/ast/module.f.d.ts +47 -0
  31. package/fjs/djs/ast/module.f.js +9 -0
  32. package/fjs/djs/tokenizer/module.f.js +2 -2
  33. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  34. package/fjs/djs/tokenizer/proof.f.js +70 -16
  35. package/fjs/effects/module.f.d.ts +20 -0
  36. package/fjs/effects/module.f.js +25 -1
  37. package/fjs/effects/node/module.d.ts +3 -3
  38. package/fjs/effects/node/module.f.d.ts +17 -7
  39. package/fjs/effects/node/module.f.js +22 -0
  40. package/fjs/effects/node/module.js +12 -12
  41. package/fjs/effects/node/proof.f.d.ts +1 -0
  42. package/fjs/effects/node/proof.f.js +14 -1
  43. package/fjs/effects/node/virtual/module.f.js +1 -1
  44. package/fjs/effects/proof.f.d.ts +5 -0
  45. package/fjs/effects/proof.f.js +22 -0
  46. package/fjs/emergent_testing/all.test.js +2 -1
  47. package/fjs/emergent_testing/module.f.d.ts +3 -3
  48. package/fjs/emergent_testing/module.f.js +8 -10
  49. package/fjs/emergent_testing/proof.f.js +2 -2
  50. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  51. package/fjs/fsc/module.f.js +4 -4
  52. package/fjs/fsm/module.f.js +1 -1
  53. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  54. package/fjs/js/tokenizer/module.f.js +13 -6
  55. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  56. package/fjs/{cas/mcp → mcp/cas}/module.f.js +18 -58
  57. package/fjs/mcp/evo/module.f.d.ts +32 -0
  58. package/fjs/{cas/evo/mcp → mcp/evo}/module.f.js +19 -14
  59. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +1 -0
  60. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +23 -7
  61. package/fjs/mcp/module.f.d.ts +54 -237
  62. package/fjs/mcp/module.f.js +55 -258
  63. package/fjs/mcp/proof.f.d.ts +43 -32
  64. package/fjs/mcp/proof.f.js +508 -200
  65. package/fjs/media/nix/module.f.d.ts +30 -0
  66. package/fjs/media/nix/module.f.js +166 -0
  67. package/fjs/media/nix/proof.f.d.ts +32 -0
  68. package/fjs/media/nix/proof.f.js +127 -0
  69. package/fjs/module.f.js +1 -1
  70. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  71. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  72. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  73. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  74. package/fjs/protocol/mcp/module.f.js +272 -0
  75. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  76. package/fjs/protocol/mcp/proof.f.js +208 -0
  77. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +5 -5
  78. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +11 -11
  79. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  80. package/fjs/types/range_map/module.f.d.ts +14 -13
  81. package/fjs/types/range_map/module.f.js +18 -13
  82. package/fjs/types/range_map/proof.f.js +26 -39
  83. package/fjs/types/range_set/module.f.d.ts +5 -0
  84. package/fjs/types/range_set/module.f.js +16 -0
  85. package/fjs/types/range_set/proof.f.d.ts +1 -0
  86. package/fjs/types/range_set/proof.f.js +19 -0
  87. package/package.json +5 -5
  88. package/fjs/cas/evo/mcp/module.f.d.ts +0 -27
  89. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  90. package/fjs/cas/mcp/proof.f.js +0 -545
  91. package/fjs/ci/playwright/module.f.d.ts +0 -2
  92. package/fjs/ci/playwright/module.f.js +0 -25
  93. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  94. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  95. /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
package/README.md CHANGED
@@ -60,7 +60,7 @@ The CAS is also exposed as an [MCP](https://modelcontextprotocol.io/) server so
60
60
  claude mcp add cas -- npx functionalscript m
61
61
  ```
62
62
 
63
- See [`fjs/cas/mcp/README.md`](fjs/cas/mcp/README.md) for details on the `cas_add`, `cas_get`, and `cas_list` tools.
63
+ See [`fjs/mcp/README.md`](fjs/mcp/README.md) for details on the `cas_add`, `cas_get`, and `cas_list` tools.
64
64
 
65
65
  ## Vision
66
66
 
@@ -7,9 +7,14 @@
7
7
  * AST ({@link AstRuleMeta}). Nullability (which rule can match empty input) is
8
8
  * computed once by {@link emptyTagMap} in `fjs/bnf/data`.
9
9
  *
10
+ * A failed result also carries a {@link DescentFailure}: the furthest position a
11
+ * terminal was rejected at, which — unlike the result's own index — never
12
+ * rewinds and is what diagnostics should be built from.
13
+ *
10
14
  * @module
11
15
  */
12
16
  import { type CodePoint } from '../../text/utf16/module.f.ts';
17
+ import { type TerminalRange } from '../module.f.ts';
13
18
  import { type Rule as FRule } from '../module.f.ts';
14
19
  export type AstTag = string | true | undefined;
15
20
  /**
@@ -17,9 +22,45 @@ export type AstTag = string | true | undefined;
17
22
  */
18
23
  export type DescentMatchRule<T> = (name: string, tag: AstTag, s: readonly CodePointMeta<T>[], idx: number) => DescentMatchResult<T>;
19
24
  /**
20
- * Result tuple of a descent match operation: AST node, success flag, and next index.
25
+ * Where a match ran out of road, for diagnostics.
26
+ *
27
+ * `idx` is the furthest position any terminal was tried at and rejected, and
28
+ * `expected` holds the terminals that would have allowed progress there, in the
29
+ * order the grammar tried them and without repeats.
30
+ *
31
+ * Unlike a failed result's own index, this never rewinds: a failing sequence
32
+ * item rewinds the result to the sequence's start, while the furthest failure is
33
+ * a high-water mark over the whole match — including branches the grammar
34
+ * backtracked out of. That is what makes "expected X or Y at N" possible.
35
+ *
36
+ * `idx` is `0` with an empty `expected` when the match failed without ever
37
+ * rejecting a terminal, as an empty variant does.
21
38
  */
22
- export type DescentMatchResult<T> = readonly [AstRuleMeta<T>, boolean, number];
39
+ export type DescentFailure = {
40
+ readonly idx: number;
41
+ readonly expected: readonly TerminalRange[];
42
+ };
43
+ /**
44
+ * Result of a descent match operation.
45
+ *
46
+ * `failure` is present exactly when `success` is `false`: a successful match has
47
+ * nothing to diagnose, and its `idx` already says where matching stopped. Note
48
+ * the consequence for a match that succeeds *without consuming all input* —
49
+ * `idx` still locates the position it stopped at, but the terminals that would
50
+ * have let it continue are not reported.
51
+ *
52
+ * On failure `idx` has rewound to the start of the enclosing sequence and
53
+ * locates nothing; read `failure.idx` instead.
54
+ *
55
+ * The same type describes a match in progress, where `failure` is likewise
56
+ * absent until the match ends.
57
+ */
58
+ export type DescentMatchResult<T> = {
59
+ readonly ast: AstRuleMeta<T>;
60
+ readonly success: boolean;
61
+ readonly idx: number;
62
+ readonly failure?: DescentFailure;
63
+ };
23
64
  /**
24
65
  * Entry-point recursive descent matcher.
25
66
  */
@@ -7,6 +7,10 @@
7
7
  * AST ({@link AstRuleMeta}). Nullability (which rule can match empty input) is
8
8
  * computed once by {@link emptyTagMap} in `fjs/bnf/data`.
9
9
  *
10
+ * A failed result also carries a {@link DescentFailure}: the furthest position a
11
+ * terminal was rejected at, which — unlike the result's own index — never
12
+ * rewinds and is what diagnostics should be built from.
13
+ *
10
14
  * @module
11
15
  */
12
16
  import {} from '../../text/utf16/module.f.js';
@@ -15,6 +19,20 @@ import { contains as rangeContains } from '../../types/range/module.f.js';
15
19
  import { definedEntries } from '../../types/object/module.f.js';
16
20
  import { emptyTagMap, toData } from '../data/module.f.js';
17
21
  import {} from '../module.f.js';
22
+ /**
23
+ * Folds one rejected terminal into the furthest-failure record: further along
24
+ * replaces, the same position accumulates (ignoring repeats), earlier is
25
+ * discarded.
26
+ */
27
+ const recordFailure = (failure, idx, terminal) => {
28
+ if (idx > failure.idx) {
29
+ return { idx, expected: [terminal] };
30
+ }
31
+ if (idx < failure.idx || failure.expected.includes(terminal)) {
32
+ return failure;
33
+ }
34
+ return { idx, expected: [...failure.expected, terminal] };
35
+ };
18
36
  /**
19
37
  * Creates a recursive descent parser that preserves metadata for each consumed
20
38
  * code point.
@@ -29,11 +47,14 @@ export const descentParser = (fr) => {
29
47
  // grammar recursion depth — right-recursive rules (e.g. repeat0Plus chains) no longer
30
48
  // overflow on long input (see the longInput proof group).
31
49
  const f = (name, tag, cp, idx) => {
32
- const mrSuccess = (tag, sequence, idx) => [{ tag, sequence }, true, idx];
33
- const mrFail = (tag, sequence, idx) => [{ tag, sequence }, false, idx];
50
+ const mrSuccess = (tag, sequence, idx) => ({ ast: { tag, sequence }, success: true, idx });
51
+ const mrFail = (tag, sequence, idx) => ({ ast: { tag, sequence }, success: false, idx });
34
52
  let stack = null;
35
53
  let task = { name, tag, idx };
36
54
  let result = mrFail(undefined, [], idx);
55
+ // High-water mark across the whole match, so it survives the rewinds a
56
+ // failing sequence item does to `result`.
57
+ let furthest = { idx: 0, expected: [] };
37
58
  while (true) {
38
59
  if (task !== null) {
39
60
  const { name, tag, idx } = task;
@@ -43,14 +64,17 @@ export const descentParser = (fr) => {
43
64
  // later `task` assignments that `name`'s narrowing depends on.
44
65
  const rule = data[0][name];
45
66
  if (typeof rule === 'number') {
46
- const emptyTag = emptyTags[name];
47
- if (idx >= cp.length) {
48
- result = emptyTag === undefined ? mrFail(emptyTag, [], idx) : mrSuccess(emptyTag, [], idx);
67
+ // No nullable case: `emptyTagOf` in `bnf/data` returns `undefined`
68
+ // for every terminal, so `emptyTags[name]` here is always
69
+ // `undefined` and a terminal either consumes one symbol or fails.
70
+ if (idx < cp.length && rangeContains(...rangeDecode(rule))(cp[idx][0])) {
71
+ result = mrSuccess(tag, [cp[idx]], idx + 1);
49
72
  }
50
73
  else {
51
- const cpi = cp[idx];
52
- const range = rangeDecode(rule);
53
- result = rangeContains(...range)(cpi[0]) ? mrSuccess(tag, [cpi], idx + 1) : mrFail(emptyTag, [], idx);
74
+ // The only place a terminal is rejected, so the only place
75
+ // the furthest failure can advance.
76
+ furthest = recordFailure(furthest, idx, rule);
77
+ result = mrFail(undefined, [], idx);
54
78
  }
55
79
  }
56
80
  else if (rule instanceof Array) {
@@ -78,12 +102,13 @@ export const descentParser = (fr) => {
78
102
  continue;
79
103
  }
80
104
  if (stack === null) {
81
- return result;
105
+ // A success has nothing to diagnose; only a failure carries it.
106
+ return result.success ? result : { ...result, failure: furthest };
82
107
  }
83
108
  const frame = stack.top;
84
109
  stack = stack.rest;
85
110
  if (frame.kind === 'seq') {
86
- const [astRule, success, nidx] = result;
111
+ const { ast: astRule, success, idx: nidx } = result;
87
112
  if (success === false) {
88
113
  result = mrFail(frame.tag, [], frame.startIdx);
89
114
  }
@@ -102,8 +127,8 @@ export const descentParser = (fr) => {
102
127
  else {
103
128
  // success that consumed input wins immediately: the frame stays popped and
104
129
  // `result` propagates to the frame below, matching the recursive `return m`.
105
- if (!(result[1] && frame.idx !== result[2])) {
106
- const emptyResult = result[1] ? result : frame.emptyResult;
130
+ if (!(result.success && frame.idx !== result.idx)) {
131
+ const emptyResult = result.success ? result : frame.emptyResult;
107
132
  const entryIndex = frame.entryIndex + 1;
108
133
  if (entryIndex < frame.entries.length) {
109
134
  stack = { top: { ...frame, entryIndex, emptyResult }, rest: stack };
@@ -3,4 +3,5 @@ export declare const proof: {
3
3
  descentParser: (() => void)[];
4
4
  longInput: (() => void)[];
5
5
  descentParserWithMeta: (() => void)[];
6
+ furthestFailure: (() => void)[];
6
7
  };
@@ -4,8 +4,12 @@ import { commaJoin0Plus, option, range, repeat0Plus, set } from '../module.f.js'
4
4
  import { deterministic } from '../testlib.f.js';
5
5
  import { emptyTagMap, toData } from '../data/module.f.js';
6
6
  import { descentParser } from './module.f.js';
7
- import { assertEq } from '../../asserts/module.f.js';
7
+ import { assertEq, assertNotNullish } from '../../asserts/module.f.js';
8
8
  const mapCodePoint = (cp) => [cp, undefined];
9
+ // The code point of a one-character string, for expectations that would
10
+ // otherwise spell it as a bare number. Goes through the module's own
11
+ // conversion, the same one that builds the parser's input.
12
+ const cp1 = (s) => toArray(stringToCodePointList(s))[0];
9
13
  const descentParserCpOnly = (m, name, cp) => {
10
14
  const cpm = toArray(map(mapCodePoint)(cp));
11
15
  return m(name, cpm);
@@ -83,52 +87,59 @@ export const proof = {
83
87
  const m = descentParser(emptyRule);
84
88
  const mr = m("", []);
85
89
  const result = JSON.stringify(mr);
86
- if (result !== '[{"sequence":[]},true,0]') {
90
+ if (result !== '{"ast":{"sequence":[]},"success":true,"idx":0}') {
87
91
  throw result;
88
92
  }
89
93
  },
90
94
  () => {
91
95
  const emptyRule = '';
92
96
  const m = descentParser(emptyRule);
93
- const mr = descentParserCpOnly(m, "", [65, 70]);
97
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('AF')));
94
98
  const result = JSON.stringify(mr);
95
- if (result !== '[{"sequence":[]},true,0]') {
99
+ if (result !== '{"ast":{"sequence":[]},"success":true,"idx":0}') {
96
100
  throw result;
97
101
  }
98
102
  },
99
103
  () => {
104
+ // Literal code point on purpose. Elsewhere both sides of the
105
+ // assertion come from `stringToCodePointList` — the input is built
106
+ // with it and the expectation interpolates `cp1`, which calls it
107
+ // too — so a change to the conversion would move both sides
108
+ // together and the test would still pass. Pinning `A` to 65 here
109
+ // covers the conversion itself.
100
110
  const terminalRangeRule = range('AF');
101
111
  const m = descentParser(terminalRangeRule);
102
- const mr = descentParserCpOnly(m, "", [65]);
112
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('A')));
103
113
  const result = JSON.stringify(mr);
104
- if (result !== '[{"sequence":[[65,null]]},true,1]') {
114
+ if (result !== '{"ast":{"sequence":[[65,null]]},"success":true,"idx":1}') {
105
115
  throw result;
106
116
  }
107
117
  },
108
118
  () => {
109
119
  const terminalRangeRule = range('AF');
110
120
  const m = descentParser(terminalRangeRule);
111
- const mr = descentParserCpOnly(m, "", [64]);
121
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('@')));
112
122
  const result = JSON.stringify(mr);
113
- if (result !== '[{"sequence":[]},false,0]') {
123
+ if (result !== `{"ast":{"sequence":[]},"success":false,"idx":0,"failure":{"idx":0,"expected":[${range('AF')}]}}`) {
114
124
  throw result;
115
125
  }
116
126
  },
117
127
  () => {
118
128
  const variantRule = { 'a': range('AA'), 'b': range('BB') };
119
129
  const m = descentParser(variantRule);
120
- const mr = descentParserCpOnly(m, "", [65]);
130
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('A')));
121
131
  const result = JSON.stringify(mr);
122
- if (result !== '[{"tag":"a","sequence":[[65,null]]},true,1]') {
132
+ if (result !== `{"ast":{"tag":"a","sequence":[[${cp1('A')},null]]},"success":true,"idx":1}`) {
123
133
  throw result;
124
134
  }
125
135
  },
126
136
  () => {
127
137
  const variantRule = { 'a': range('AA'), 'b': range('BB') };
128
138
  const m = descentParser(variantRule);
129
- const mr = descentParserCpOnly(m, "", [64]);
139
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('@')));
130
140
  const result = JSON.stringify(mr);
131
- if (result !== '[{"sequence":[]},false,0]') {
141
+ // Both branches were rejected at 0, so both terminals are expected there.
142
+ if (result !== `{"ast":{"sequence":[]},"success":false,"idx":0,"failure":{"idx":0,"expected":[${range('AA')},${range('BB')}]}}`) {
132
143
  throw result;
133
144
  }
134
145
  },
@@ -138,7 +149,7 @@ export const proof = {
138
149
  const m = descentParser(variantRule);
139
150
  const mr = m("", []);
140
151
  const result = JSON.stringify(mr);
141
- if (result !== '[{"tag":"e","sequence":[]},true,0]') {
152
+ if (result !== '{"ast":{"tag":"e","sequence":[]},"success":true,"idx":0}') {
142
153
  throw result;
143
154
  }
144
155
  },
@@ -146,9 +157,9 @@ export const proof = {
146
157
  const emptyRule = '';
147
158
  const variantRule = { 'e': emptyRule, 'a': range('AA') };
148
159
  const m = descentParser(variantRule);
149
- const mr = descentParserCpOnly(m, "", [64]);
160
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('@')));
150
161
  const result = JSON.stringify(mr);
151
- if (result !== '[{"tag":"e","sequence":[]},true,0]') {
162
+ if (result !== '{"ast":{"tag":"e","sequence":[]},"success":true,"idx":0}') {
152
163
  throw result;
153
164
  }
154
165
  },
@@ -157,25 +168,29 @@ export const proof = {
157
168
  const m = descentParser(emptyVariantRule);
158
169
  const mr = m("", []);
159
170
  const result = JSON.stringify(mr);
160
- if (result !== '[{"sequence":[]},false,0]') {
171
+ // A variant with no branches fails without ever trying a terminal,
172
+ // so there is nothing to expect.
173
+ if (result !== '{"ast":{"sequence":[]},"success":false,"idx":0,"failure":{"idx":0,"expected":[]}}') {
161
174
  throw result;
162
175
  }
163
176
  },
164
177
  () => {
165
178
  const stringRule = 'AB';
166
179
  const m = descentParser(stringRule);
167
- const mr = descentParserCpOnly(m, "", [65, 66]);
180
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('AB')));
168
181
  const result = JSON.stringify(mr);
169
- if (result !== '[{"sequence":[{"sequence":[[65,null]]},{"sequence":[[66,null]]}]},true,2]') {
182
+ if (result !== `{"ast":{"sequence":[{"sequence":[[${cp1('A')},null]]},{"sequence":[[${cp1('B')},null]]}]},"success":true,"idx":2}`) {
170
183
  throw result;
171
184
  }
172
185
  },
173
186
  () => {
174
187
  const stringRule = 'AB';
175
188
  const m = descentParser(stringRule);
176
- const mr = descentParserCpOnly(m, "", [65, 67]);
189
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('AC')));
177
190
  const result = JSON.stringify(mr);
178
- if (result !== '[{"sequence":[]},false,0]') {
191
+ // The result index rewound to the sequence's start, but the furthest
192
+ // failure kept the position where 'B' was actually rejected.
193
+ if (result !== `{"ast":{"sequence":[]},"success":false,"idx":0,"failure":{"idx":1,"expected":[${range('BB')}]}}`) {
179
194
  throw result;
180
195
  }
181
196
  },
@@ -186,9 +201,9 @@ export const proof = {
186
201
  const digitRule = range('09');
187
202
  const numberRule = [optionalMinusRule, digitRule];
188
203
  const m = descentParser(numberRule);
189
- const mr = descentParserCpOnly(m, "", [50]);
204
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('2')));
190
205
  const result = JSON.stringify(mr);
191
- if (result !== '[{"sequence":[{"tag":"none","sequence":[]},{"sequence":[[50,null]]}]},true,1]') {
206
+ if (result !== `{"ast":{"sequence":[{"tag":"none","sequence":[]},{"sequence":[[${cp1('2')},null]]}]},"success":true,"idx":1}`) {
192
207
  throw result;
193
208
  }
194
209
  },
@@ -199,9 +214,9 @@ export const proof = {
199
214
  const digitRule = range('09');
200
215
  const numberRule = [optionalMinusRule, digitRule];
201
216
  const m = descentParser(numberRule);
202
- const mr = descentParserCpOnly(m, "", [45, 50]);
217
+ const mr = descentParserCpOnly(m, "", toArray(stringToCodePointList('-2')));
203
218
  const result = JSON.stringify(mr);
204
- if (result !== '[{"sequence":[{"tag":"minus","sequence":[[45,null]]},{"sequence":[[50,null]]}]},true,2]') {
219
+ if (result !== `{"ast":{"sequence":[{"tag":"minus","sequence":[[${cp1('-')},null]]},{"sequence":[[${cp1('2')},null]]}]},"success":true,"idx":2}`) {
205
220
  throw result;
206
221
  }
207
222
  },
@@ -214,7 +229,8 @@ export const proof = {
214
229
  const m = descentParser(numberRule);
215
230
  const mr = m("", []);
216
231
  const result = JSON.stringify(mr);
217
- if (result !== '[{"sequence":[]},false,0]') {
232
+ // Past the end: '-' and then the digit range were both rejected at 0.
233
+ if (result !== `{"ast":{"sequence":[]},"success":false,"idx":0,"failure":{"idx":0,"expected":[${range('--')},${range('09')}]}}`) {
218
234
  throw result;
219
235
  }
220
236
  },
@@ -223,7 +239,7 @@ export const proof = {
223
239
  const expect = (s, expected) => {
224
240
  const cp = toArray(stringToCodePointList(s));
225
241
  const mr = descentParserCpOnly(m, '', cp);
226
- const success = mr[1] && mr[2] === cp.length;
242
+ const success = mr.success && mr.idx === cp.length;
227
243
  assertEq(success, expected, mr);
228
244
  };
229
245
  expect('a', true);
@@ -243,7 +259,7 @@ export const proof = {
243
259
  const expect = (s, expected) => {
244
260
  const cp = toArray(stringToCodePointList(s));
245
261
  const mr = descentParserCpOnly(m, 'value', cp);
246
- const success = mr[1] && mr[2] === cp.length;
262
+ const success = mr.success && mr.idx === cp.length;
247
263
  assertEq(success, expected, mr);
248
264
  };
249
265
  expect('', false);
@@ -257,7 +273,7 @@ export const proof = {
257
273
  const expect = (s, expected) => {
258
274
  const cp = toArray(stringToCodePointList(s));
259
275
  const mr = descentParserCpOnly(m, '', cp);
260
- const success = mr[1] && mr[2] === cp.length;
276
+ const success = mr.success && mr.idx === cp.length;
261
277
  assertEq(success, expected, mr);
262
278
  };
263
279
  expect(' true ', true);
@@ -293,7 +309,7 @@ export const proof = {
293
309
  const name = toData(rule)[1];
294
310
  const m = descentParser(rule);
295
311
  const cp = toArray(stringToCodePointList(' '.repeat(10000)));
296
- const [, ok, idx] = descentParserCpOnly(m, name, cp);
312
+ const { success: ok, idx } = descentParserCpOnly(m, name, cp);
297
313
  assertEq(ok, true);
298
314
  assertEq(idx, 10000);
299
315
  },
@@ -304,7 +320,7 @@ export const proof = {
304
320
  const m = descentParser(deterministic());
305
321
  const n = 5000;
306
322
  const cp = toArray(stringToCodePointList('['.repeat(n) + ']'.repeat(n)));
307
- const [, ok, idx] = descentParserCpOnly(m, '', cp);
323
+ const { success: ok, idx } = descentParserCpOnly(m, '', cp);
308
324
  assertEq(ok, true);
309
325
  assertEq(idx, n * 2);
310
326
  },
@@ -317,11 +333,36 @@ export const proof = {
317
333
  const digitRule = range('09');
318
334
  const numberRule = [optionalMinusRule, digitRule];
319
335
  const m = descentParser(numberRule);
320
- const mr = m("", [[45, 'minus'], [50, 'two']]);
336
+ const mr = m("", [[cp1('-'), 'minus'], [cp1('2'), 'two']]);
321
337
  const result = JSON.stringify(mr);
322
- if (result !== '[{"sequence":[{"tag":"minus","sequence":[[45,"minus"]]},{"sequence":[[50,"two"]]}]},true,2]') {
338
+ if (result !== `{"ast":{"sequence":[{"tag":"minus","sequence":[[${cp1('-')},"minus"]]},{"sequence":[[${cp1('2')},"two"]]}]},"success":true,"idx":2}`) {
323
339
  throw result;
324
340
  }
325
341
  },
326
342
  ],
343
+ furthestFailure: [
344
+ () => {
345
+ // A branch rejected *before* the high-water mark must not pull it back:
346
+ // `x` gets to index 1 before failing, then `y` fails at 0.
347
+ const m = descentParser({ x: ['A', 'B'], y: 'B' });
348
+ const { success: ok, idx, failure } = descentParserCpOnly(m, '', toArray(stringToCodePointList('AC')));
349
+ assertEq(ok, false);
350
+ assertEq(idx, 0);
351
+ // A failed match always carries a failure; that is the contract.
352
+ const f = assertNotNullish(failure);
353
+ assertEq(f.idx, 1);
354
+ assertEq(f.expected.length, 1);
355
+ assertEq(f.expected[0], range('BB'));
356
+ },
357
+ () => {
358
+ // The same terminal rejected at the same index by two branches is
359
+ // expected once, not twice.
360
+ const m = descentParser({ x: ['A', 'B'], y: ['A', 'B', 'C'] });
361
+ const { failure } = descentParserCpOnly(m, '', toArray(stringToCodePointList('AC')));
362
+ const f = assertNotNullish(failure);
363
+ assertEq(f.idx, 1);
364
+ assertEq(f.expected.length, 1);
365
+ assertEq(f.expected[0], range('BB'));
366
+ },
367
+ ],
327
368
  };
@@ -55,7 +55,7 @@ export const dispatchMap = (ruleSet) => {
55
55
  const rule = ruleSet[name];
56
56
  if (typeof rule === 'number') {
57
57
  const range = rangeDecode(rule);
58
- const dispatch = dispatchOp.fromRange(range)({ tag: undefined, rules: [] });
58
+ const dispatch = dispatchOp.fromRange({ tag: undefined, rules: [] })(range);
59
59
  const dr = { emptyTag: undefined, rangeMap: dispatch };
60
60
  return { ...dm, [name]: dr };
61
61
  }
@@ -126,7 +126,7 @@ export const parserRuleSet = (ruleSet) => {
126
126
  return mrSuccess(emptyTag, [], emptyTag === undefined ? null : cp);
127
127
  }
128
128
  const [cp0] = cp;
129
- const dr = dispatchOp.get(cp0)(rangeMap);
129
+ const dr = dispatchOp.get(rangeMap)(cp0);
130
130
  if (dr === null) {
131
131
  return emptyTag === undefined
132
132
  ? mrFail(emptyTag, [], cp)
@@ -17,7 +17,10 @@
17
17
  * subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
18
18
  * `Cache` therefore tracks, per subject, every revision hash seen and every
19
19
  * hash referenced as somebody's parent; heads are the set difference between
20
- * the two, computed at read time ({@link headsOf}). Storing both sets rather
20
+ * the two, computed at read time ({@link headsOf}). Alongside them it records
21
+ * which of the seen revisions are `archived`, so {@link Evo.list} can classify
22
+ * a subject as active or archived from its heads' flags
23
+ * ({@link subjectListed}) without touching the store. Storing both sets rather
21
24
  * than a running head list is what makes folding revisions truly order
22
25
  * independent: `cas.list()` (used by {@link buildCache} to scan an existing
23
26
  * store) returns hashes in hash order, not revision ancestry, so a child can
@@ -97,14 +100,22 @@ export type RevisionData = {
97
100
  readonly generation?: number | undefined;
98
101
  };
99
102
  /**
100
- * Per-subject bookkeeping: every revision hash seen for the subject, and
101
- * every hash any of those revisions names as a parent. See the module doc
102
- * for why both sets are kept (rather than a running head list) and
103
- * {@link headsOf} for how heads are derived from them.
103
+ * Per-subject bookkeeping: every revision hash seen for the subject, every
104
+ * hash any of those revisions names as a parent, and which of the seen
105
+ * revisions are `archived`. See the module doc for why the first two sets are
106
+ * kept (rather than a running head list), {@link headsOf} for how heads are
107
+ * derived from them, and {@link subjectListed} for how `archived` classifies a
108
+ * subject once its heads are known.
109
+ *
110
+ * `archived` is keyed by revision hash, not by subject, for the same reason
111
+ * heads are computed at read time: which revisions are heads is only known
112
+ * once the whole store has been folded in, so a per-subject archived flag
113
+ * would have to be revised every time a later fold changes the head set.
104
114
  */
105
115
  export type SubjectState = {
106
116
  readonly hashes: readonly Hash[];
107
117
  readonly parents: readonly Hash[];
118
+ readonly archived: readonly Hash[];
108
119
  };
109
120
  /** In-memory index: subject → its {@link SubjectState}. */
110
121
  export type Cache = {
@@ -137,7 +148,7 @@ export declare const initEvo: <O extends Operation>(cas: Cas<O>) => Effect<O | M
137
148
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
138
149
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
139
150
  * ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
140
- * (`fjs/cas/mcp`) are two ways to reach the same store — a plain `cas_add`
151
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
141
152
  * call can store a revision blob without going through {@link addRevision},
142
153
  * and this is what keeps the cache honest about it without rescanning the
143
154
  * whole store.
@@ -182,8 +193,17 @@ export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKe
182
193
  export declare const readRevision: <O extends Operation>(cas: Cas<O>) => (hash: Hash) => Effect<O, Result<RevisionData, string>>;
183
194
  /** The Evo API described in `fjs/cas/evo/README.md`, bound to a `Cas<O>` and its cache slot. */
184
195
  export type Evo<O extends Operation> = {
185
- /** Returns every subject with at least one stored revision. */
186
- readonly list: () => Effect<MemOp, readonly Subject[]>;
196
+ /**
197
+ * Returns the subjects matching a status filter: the active ones by
198
+ * default, the archived ones when `archived` is `true`. A subject's status
199
+ * is derived from its current heads — see {@link subjectListed}, which
200
+ * also explains why a subject with no current heads is in neither result.
201
+ *
202
+ * There is deliberately no all-subjects mode: nothing needs one yet, and
203
+ * adding it later is a compatible extension of this parameter, while
204
+ * removing it would not be.
205
+ */
206
+ readonly list: (archived?: true) => Effect<MemOp, readonly Subject[]>;
187
207
  /** Returns the current head hashes of `subject` (empty if unknown). */
188
208
  readonly head: (subject: Subject) => Effect<MemOp, readonly Hash[]>;
189
209
  /** Adds a new head; see {@link addRevision}. */
@@ -17,7 +17,10 @@
17
17
  * subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
18
18
  * `Cache` therefore tracks, per subject, every revision hash seen and every
19
19
  * hash referenced as somebody's parent; heads are the set difference between
20
- * the two, computed at read time ({@link headsOf}). Storing both sets rather
20
+ * the two, computed at read time ({@link headsOf}). Alongside them it records
21
+ * which of the seen revisions are `archived`, so {@link Evo.list} can classify
22
+ * a subject as active or archived from its heads' flags
23
+ * ({@link subjectListed}) without touching the store. Storing both sets rather
21
24
  * than a running head list is what makes folding revisions truly order
22
25
  * independent: `cas.list()` (used by {@link buildCache} to scan an existing
23
26
  * store) returns hashes in hash order, not revision ancestry, so a child can
@@ -58,7 +61,7 @@ import { isNotFound } from '../../effects/node/module.f.js';
58
61
  export const emptyCache = { bySubject: {} };
59
62
  /** Canonical JSON encoder for a `Revision` — key order carries no meaning for detection. */
60
63
  const toJson = stringify(identity);
61
- const emptySubjectState = { hashes: [], parents: [] };
64
+ const emptySubjectState = { hashes: [], parents: [], archived: [] };
62
65
  /** Adds every item of `items` to `set` that isn't already there, preserving `set`'s existing order. */
63
66
  const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc : [...acc, h], set);
64
67
  /**
@@ -76,12 +79,37 @@ const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc
76
79
  const canonicalHash = (h) => vecToCBase32(unwrap(cBase32ToVec(h)));
77
80
  /** A subject's current heads: revision hashes seen that no other revision of the same subject names as a parent. */
78
81
  const headsOf = (state) => state.hashes.filter(h => !state.parents.includes(h));
82
+ /**
83
+ * Whether a subject in `state` belongs in {@link Evo.list}'s result for the
84
+ * given `archived` filter — the subject-level status derived from its
85
+ * revision-level `archived` flags:
86
+ *
87
+ * - **active** — at least one current head is not archived. This is the
88
+ * default result set (`archived` omitted).
89
+ * - **archived** — the subject has at least one current head and every one of
90
+ * them is archived (`archived: true`).
91
+ *
92
+ * Concurrent heads can disagree, and the two rules resolve that the same way:
93
+ * one unarchived head keeps the whole subject active, because a subject is
94
+ * only done evolving when nothing left to build on remains. A subject with no
95
+ * current heads is neither active nor archived and appears in no result — the
96
+ * status is a statement about heads, and there is nothing to state. That case
97
+ * needs the explicit `heads.length` test only in the archived branch, since
98
+ * "every head is archived" is vacuously true of no heads at all.
99
+ */
100
+ const subjectListed = (archived) => (state) => {
101
+ const heads = headsOf(state);
102
+ const unarchived = heads.filter(h => !state.archived.includes(h));
103
+ return archived === undefined
104
+ ? unarchived.length !== 0
105
+ : heads.length !== 0 && unarchived.length === 0;
106
+ };
79
107
  /**
80
108
  * Folds one more stored revision into `cache`: `hash` joins its subject's
81
- * `hashes` set, and `revision.parents` (canonicalized, see
82
- * {@link canonicalHash}) join its `parents` set. Order independent (see the
83
- * module doc) used both for a full-store scan and for a single
84
- * incremental `add`.
109
+ * `hashes` set, `revision.parents` (canonicalized, see {@link canonicalHash})
110
+ * join its `parents` set, and `hash` also joins the `archived` set when the
111
+ * revision carries `archived: true`. Order independent (see the module doc)
112
+ * used both for a full-store scan and for a single incremental `add`.
85
113
  *
86
114
  * Looks `revision.subject` up via {@link at} (own-property only), not plain
87
115
  * bracket indexing: a subject is an arbitrary caller-supplied string
@@ -96,6 +124,7 @@ const addRevisionToCache = (hash, revision) => (cache) => {
96
124
  const state = {
97
125
  hashes: union(existing.hashes)([hash]),
98
126
  parents: union(existing.parents)(revision.parents.map(canonicalHash)),
127
+ archived: union(existing.archived)(revision.archived === undefined ? [] : [hash]),
99
128
  };
100
129
  return { bySubject: { ...cache.bySubject, [revision.subject]: state } };
101
130
  };
@@ -141,7 +170,7 @@ const foldIntoCache = (cacheKey) => (hash) => (revision) => eff(read(cacheKey))
141
170
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
142
171
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
143
172
  * ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
144
- * (`fjs/cas/mcp`) are two ways to reach the same store — a plain `cas_add`
173
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
145
174
  * call can store a revision blob without going through {@link addRevision},
146
175
  * and this is what keeps the cache honest about it without rescanning the
147
176
  * whole store.
@@ -407,9 +436,13 @@ export const readRevision = (cas) => (hash) => {
407
436
  };
408
437
  /** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
409
438
  export const evo = (cas) => (cacheKey) => ({
410
- list: () => eff(read(cacheKey))
411
- .step(cache => pure(definedEntries(cache.bySubject).map(([subject]) => subject)))
412
- .value,
439
+ list: archived => {
440
+ const listed = subjectListed(archived);
441
+ return eff(read(cacheKey))
442
+ .step(cache => pure(definedEntries(cache.bySubject)
443
+ .flatMap(([subject, state]) => listed(state) ? [subject] : [])))
444
+ .value;
445
+ },
413
446
  head: subject => eff(read(cacheKey))
414
447
  .step(cache => {
415
448
  const state = at(subject)(cache.bySubject);
@@ -9,6 +9,10 @@ export declare const proof: {
9
9
  buildCacheOrderIndependentWhenChildScannedBeforeParent: () => void;
10
10
  buildCacheCanonicalizesNonCanonicalParentHashes: () => void;
11
11
  addRevisionBuildsHeadsAcrossChainAndFork: () => void;
12
+ listPartitionsSubjectsByHeadArchivedFlag: () => void;
13
+ listTreatsDisagreeingHeadsAsActive: () => void;
14
+ listIgnoresArchivedRevisionsThatAreNoLongerHeads: () => void;
15
+ listExcludesSubjectWithNoCurrentHeads: () => void;
12
16
  addRevisionIdempotentOnDuplicateContent: () => void;
13
17
  addRevisionCanonicalizesParentSpellingBeforeSerializing: () => void;
14
18
  addRevisionResolvesSubjectFromSingleParent: () => void;