functionalscript 0.39.0 → 0.40.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 (88) 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 +1 -1
  8. package/fjs/cas/evo/module.f.js +1 -1
  9. package/fjs/cas/evo/proof.f.js +1 -1
  10. package/fjs/ci/config/module.f.d.ts +7 -4
  11. package/fjs/ci/config/module.f.js +20 -5
  12. package/fjs/ci/module.f.js +12 -5
  13. package/fjs/ci/nix/module.f.d.ts +61 -0
  14. package/fjs/ci/nix/module.f.js +92 -0
  15. package/fjs/ci/nix/proof.f.d.ts +23 -0
  16. package/fjs/ci/nix/proof.f.js +109 -0
  17. package/fjs/ci/node/module.f.d.ts +23 -1
  18. package/fjs/ci/node/module.f.js +47 -3
  19. package/fjs/ci/proof.f.d.ts +2 -0
  20. package/fjs/ci/proof.f.js +47 -9
  21. package/fjs/dev/module.f.d.ts +1 -0
  22. package/fjs/dev/module.f.js +13 -2
  23. package/fjs/dev/update/module.f.d.ts +11 -0
  24. package/fjs/dev/update/module.f.js +20 -0
  25. package/fjs/dev/update/proof.f.d.ts +7 -0
  26. package/fjs/dev/update/proof.f.js +35 -0
  27. package/fjs/djs/ast/module.f.d.ts +47 -0
  28. package/fjs/djs/ast/module.f.js +9 -0
  29. package/fjs/djs/tokenizer/module.f.js +2 -2
  30. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  31. package/fjs/djs/tokenizer/proof.f.js +70 -16
  32. package/fjs/effects/node/module.d.ts +3 -3
  33. package/fjs/effects/node/module.f.d.ts +17 -7
  34. package/fjs/effects/node/module.f.js +22 -0
  35. package/fjs/effects/node/module.js +12 -12
  36. package/fjs/effects/node/proof.f.d.ts +1 -0
  37. package/fjs/effects/node/proof.f.js +14 -1
  38. package/fjs/effects/node/virtual/module.f.js +1 -1
  39. package/fjs/emergent_testing/all.test.js +2 -1
  40. package/fjs/emergent_testing/module.f.d.ts +3 -3
  41. package/fjs/emergent_testing/module.f.js +8 -10
  42. package/fjs/emergent_testing/proof.f.js +2 -2
  43. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  44. package/fjs/fsc/module.f.js +4 -4
  45. package/fjs/fsm/module.f.js +1 -1
  46. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  47. package/fjs/js/tokenizer/module.f.js +13 -6
  48. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  49. package/fjs/{cas/mcp → mcp/cas}/module.f.js +18 -58
  50. package/fjs/mcp/evo/module.f.d.ts +27 -0
  51. package/fjs/{cas/evo/mcp → mcp/evo}/module.f.js +9 -9
  52. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +7 -7
  53. package/fjs/mcp/module.f.d.ts +54 -237
  54. package/fjs/mcp/module.f.js +55 -258
  55. package/fjs/mcp/proof.f.d.ts +43 -32
  56. package/fjs/mcp/proof.f.js +508 -200
  57. package/fjs/media/nix/module.f.d.ts +30 -0
  58. package/fjs/media/nix/module.f.js +166 -0
  59. package/fjs/media/nix/proof.f.d.ts +32 -0
  60. package/fjs/media/nix/proof.f.js +127 -0
  61. package/fjs/module.f.js +1 -1
  62. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  63. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  64. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  65. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  66. package/fjs/protocol/mcp/module.f.js +272 -0
  67. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  68. package/fjs/protocol/mcp/proof.f.js +208 -0
  69. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +5 -5
  70. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +11 -11
  71. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  72. package/fjs/types/range_map/module.f.d.ts +14 -13
  73. package/fjs/types/range_map/module.f.js +18 -13
  74. package/fjs/types/range_map/proof.f.js +26 -39
  75. package/fjs/types/range_set/module.f.d.ts +5 -0
  76. package/fjs/types/range_set/module.f.js +16 -0
  77. package/fjs/types/range_set/proof.f.d.ts +1 -0
  78. package/fjs/types/range_set/proof.f.js +19 -0
  79. package/package.json +5 -5
  80. package/fjs/cas/evo/mcp/module.f.d.ts +0 -27
  81. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  82. package/fjs/cas/mcp/proof.f.js +0 -545
  83. package/fjs/ci/playwright/module.f.d.ts +0 -2
  84. package/fjs/ci/playwright/module.f.js +0 -25
  85. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  86. /package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +0 -0
  87. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  88. /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)
@@ -137,7 +137,7 @@ export declare const initEvo: <O extends Operation>(cas: Cas<O>) => Effect<O | M
137
137
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
138
138
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
139
139
  * ({@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`
140
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
141
141
  * call can store a revision blob without going through {@link addRevision},
142
142
  * and this is what keeps the cache honest about it without rescanning the
143
143
  * whole store.
@@ -141,7 +141,7 @@ const foldIntoCache = (cacheKey) => (hash) => (revision) => eff(read(cacheKey))
141
141
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
142
142
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
143
143
  * ({@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`
144
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
145
145
  * call can store a revision blob without going through {@link addRevision},
146
146
  * and this is what keeps the cache honest about it without rescanning the
147
147
  * whole store.
@@ -518,7 +518,7 @@ export const proof = {
518
518
  // A raw CAS write (e.g. `cas_add`) of valid revision content is folded
519
519
  // into the cache exactly as `addRevision` would, without going through
520
520
  // `evo.add` — this is what keeps `cas_add` and `evo_add` writes to the
521
- // same store consistent (see `fjs/cas/mcp`).
521
+ // same store consistent (see `fjs/mcp`).
522
522
  syncRevisionFoldsValidRevisionIntoCache: () => {
523
523
  const c = fileCas(sha256)(home);
524
524
  const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Centralized version pins and OS images used by the CI generator: runner
3
- * images, tool versions (Bun, Deno, Playwright, Rust, Node, Wasmtime, Wasmer,
4
- * TSGO).
3
+ * images, tool versions (Bun, Deno, Rust, Node, Wasmtime, Wasmer, TSGO).
5
4
  *
6
5
  * @module
7
6
  */
@@ -22,12 +21,15 @@ export declare const images: {
22
21
  export declare const functionalscript: '0.38.0';
23
22
  export declare const bun = "1.3.14";
24
23
  export declare const deno = "2.9.4";
25
- export declare const playwright = "1.62.0";
26
24
  export declare const node: {
27
- readonly default: '26.5.0';
25
+ readonly default: '26.5.1';
28
26
  readonly node22: '22.23.1';
29
27
  readonly node24: '24.18.0';
30
28
  };
29
+ export declare const nixpkgs: {
30
+ readonly ref: 'nixos-26.05';
31
+ readonly commit: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183';
32
+ };
31
33
  export declare const wasmtime = "47.0.2";
32
34
  export declare const wasmer = "7.2.1";
33
35
  export declare const actions: {
@@ -38,5 +40,6 @@ export declare const actions: {
38
40
  readonly 'oven-sh/setup-bun': 'v2.2.0';
39
41
  readonly 'bytecodealliance/actions/wasmtime/setup': 'v1.1.3';
40
42
  readonly 'wasmerio/setup-wasmer': 'v3.1';
43
+ readonly 'cachix/install-nix-action': 'v31.11.0';
41
44
  readonly 'dtolnay/rust-toolchain': '1.97.1';
42
45
  };
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Centralized version pins and OS images used by the CI generator: runner
3
- * images, tool versions (Bun, Deno, Playwright, Rust, Node, Wasmtime, Wasmer,
4
- * TSGO).
3
+ * images, tool versions (Bun, Deno, Rust, Node, Wasmtime, Wasmer, TSGO).
5
4
  *
6
5
  * @module
7
6
  */
@@ -29,14 +28,27 @@ export const functionalscript = '0.38.0';
29
28
  export const bun = '1.3.14';
30
29
  // https://deno.com/
31
30
  export const deno = '2.9.4';
32
- // https://www.npmjs.com/package/playwright
33
- export const playwright = '1.62.0';
31
+ // The Node versions the pinned Nixpkgs snapshot below provides — read from
32
+ // `pkgs/development/web/nodejs/v{22,24,26}.nix` at that commit. Every runtime
33
+ // uses these: `setup-node` on the GitHub-hosted runners and the generated
34
+ // flakes on the Nix jobs, which assert the version they actually get. Nixpkgs
35
+ // usually trails nodejs.org, so bump the snapshot first and copy the versions
36
+ // it offers rather than the latest release.
34
37
  // https://nodejs.org/en/download
35
38
  export const node = {
36
- default: '26.5.0',
39
+ default: '26.5.1',
37
40
  node22: '22.23.1',
38
41
  node24: '24.18.0',
39
42
  };
43
+ // Official Nixpkgs snapshot used by the generated CI flakes. `ref` is the
44
+ // stable channel the commit is accepted from; `commit` is the exact revision
45
+ // every generated `flake.nix` pins. The Node versions above come from this
46
+ // snapshot, so the two move together.
47
+ // https://channels.nixos.org/nixos-26.05/git-revision
48
+ export const nixpkgs = {
49
+ ref: 'nixos-26.05',
50
+ commit: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183',
51
+ };
40
52
  // https://github.com/bytecodealliance/wasmtime/releases
41
53
  export const wasmtime = '47.0.2';
42
54
  // https://github.com/wasmerio/wasmer/releases
@@ -60,6 +72,9 @@ export const actions = {
60
72
  'bytecodealliance/actions/wasmtime/setup': 'v1.1.3',
61
73
  // https://github.com/wasmerio/setup-wasmer
62
74
  'wasmerio/setup-wasmer': 'v3.1',
75
+ // https://github.com/marketplace/actions/install-nix
76
+ // Enables the `nix-command` and `flakes` experimental features by default.
77
+ 'cachix/install-nix-action': 'v31.11.0',
63
78
  // https://rust-lang.org/ - value is Rust version, not action version
64
79
  'dtolnay/rust-toolchain': '1.97.1',
65
80
  };
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- import { pure, step } from '../effects/module.f.js';
6
+ import { mapStep, step } from '../effects/module.f.js';
7
7
  import { access, writeUtf8File } from '../effects/node/module.f.js';
8
8
  import { functionalscript, images } from './config/module.f.js';
9
9
  import { architecture, os, toSteps, ubuntuArm } from './common/module.f.js';
10
10
  import { rustPlatformSteps, rustWasmSteps } from './rust/module.f.js';
11
- import { nodeMainSteps, nodeVersionJobs } from './node/module.f.js';
12
- import { playwrightJob } from './playwright/module.f.js';
11
+ import { nodeMainSteps, nodeNixJobs, nodeNixVersionSteps, nodeVersionJobs } from './node/module.f.js';
12
+ import { nixFlakes, nixInstall } from './nix/module.f.js';
13
13
  import { bunSteps } from './bun/module.f.js';
14
14
  import { denoSteps } from './deno/module.f.js';
15
15
  const job = (rust, nodeExtra) => (o) => (a) => {
@@ -22,12 +22,17 @@ const job = (rust, nodeExtra) => (o) => (a) => {
22
22
  ];
23
23
  return [id, { 'runs-on': image, steps: toSteps(result) }];
24
24
  };
25
+ // Every generated flake, across all job families that own one.
26
+ const nixJobs = nodeNixJobs;
27
+ // Temporary: proves the not-yet-migrated flakes still evaluate. Removed once
28
+ // the canonical Node jobs check their own flake by running through it.
29
+ const nixFlakeJob = ubuntuArm([nixInstall, ...nodeNixVersionSteps]);
25
30
  const canonicalJobs = (rust) => ({
26
31
  ...(rust ? { wasm: ubuntuArm(rustWasmSteps) } : {}),
27
32
  deno: ubuntuArm(denoSteps(functionalscript)),
28
33
  bun: ubuntuArm(bunSteps(functionalscript)),
29
34
  ...nodeVersionJobs(functionalscript),
30
- playwright: playwrightJob,
35
+ 'nix-flakes': nixFlakeJob,
31
36
  });
32
37
  export const ci = ({ nodeExtra }) => step(access('Cargo.toml'), result => {
33
38
  const rust = result[0] === 'ok';
@@ -46,6 +51,8 @@ export const ci = ({ nodeExtra }) => step(access('Cargo.toml'), result => {
46
51
  },
47
52
  jobs,
48
53
  };
49
- return step(writeUtf8File('.github/workflows/ci.yml', JSON.stringify(gha, null, ' ')), () => pure(0));
54
+ const workflowWritten = writeUtf8File('.github/workflows/ci.yml', JSON.stringify(gha, null, ' '));
55
+ const flakesWritten = step(workflowWritten, () => nixFlakes(nixJobs));
56
+ return mapStep(flakesWritten, () => 0);
50
57
  });
51
58
  export const main = () => ci({ nodeExtra: () => [] });