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
@@ -12,5 +12,7 @@ export declare const proof: {
12
12
  configuredPackageVersion: () => void;
13
13
  missingPackageJson: () => void;
14
14
  };
15
+ nixFlakes: () => void;
16
+ nixFlakeJob: () => void;
15
17
  ubuntu: () => void;
16
18
  };
package/fjs/ci/proof.f.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ci, main } from './module.f.js';
2
- import { functionalscript } from './config/module.f.js';
2
+ import { functionalscript, node } from './config/module.f.js';
3
+ import { nodeNixJobs } from './node/module.f.js';
3
4
  import { utf8, utf8ToString } from '../text/module.f.js';
4
5
  import { empty as emptyVec, isVec } from '../types/bit_vec/module.f.js';
5
6
  import { test, ubuntu, parseGitHubAction } from './common/module.f.js';
@@ -19,15 +20,22 @@ const makeState = (rust, packageJson) => ({
19
20
  ...(rust ? { 'Cargo.toml': [emptyVec] } : {}),
20
21
  },
21
22
  });
22
- const workflow = (state) => {
23
- const dotGithub = state.root['.github'];
24
- assert(typeof dotGithub === 'object' && !Array.isArray(dotGithub), dotGithub);
25
- const workflows = dotGithub['workflows'];
26
- assert(typeof workflows === 'object' && !Array.isArray(workflows), workflows);
27
- const file = workflows['ci.yml'];
23
+ const subDir = (dir, name) => {
24
+ const entity = dir[name];
25
+ assert(typeof entity === 'object' && !Array.isArray(entity), entity);
26
+ return entity;
27
+ };
28
+ const text = (dir, name) => {
29
+ const file = dir[name];
28
30
  assert(!(!Array.isArray(file) || file.length === 0), file);
29
- return unwrap(parseGitHubAction(jsonParse(utf8ToString(file[0]))));
31
+ return utf8ToString(file[0]);
30
32
  };
33
+ const path = (dir, names) => names.reduce(subDir, dir);
34
+ const workflow = (state) => {
35
+ const workflows = path(state.root, ['.github', 'workflows']);
36
+ return unwrap(parseGitHubAction(jsonParse(text(workflows, 'ci.yml'))));
37
+ };
38
+ const flake = (state, id) => text(path(state.root, ['nix', 'generated', id]), 'flake.nix');
31
39
  const run = (rust, nodeExtra = () => []) => {
32
40
  const [state, result] = virtual(makeState(rust))(ci({ nodeExtra }));
33
41
  assertEq(result, 0);
@@ -75,7 +83,6 @@ export const proof = {
75
83
  'node22',
76
84
  'node24',
77
85
  'node26',
78
- 'playwright',
79
86
  ]) {
80
87
  assert(hasRunInJob(id, 'npm ci')(gha), `expected npm ci in ${id}`);
81
88
  }
@@ -135,6 +142,37 @@ export const proof = {
135
142
  assert(hasRun(`npm install -g functionalscript@${functionalscript}`)(gha), 'expected configured-version install');
136
143
  },
137
144
  },
145
+ nixFlakes: () => {
146
+ const [state, result] = virtual(makeState(false))(main());
147
+ assertEq(result, 0);
148
+ for (const { id, packages } of nodeNixJobs) {
149
+ const [nodePackage] = packages;
150
+ assert(flake(state, id).includes(`pkgs.${nodePackage}`), `expected ${nodePackage} in the ${id} flake`);
151
+ }
152
+ },
153
+ nixFlakeJob: () => {
154
+ const gha = run(false);
155
+ const job = gha.jobs['nix-flakes'];
156
+ assert(job !== undefined, 'expected the temporary flake job');
157
+ assert(job.steps.some(step => step.uses?.startsWith('cachix/install-nix-action@') === true), 'expected a pinned Nix installer');
158
+ // Exactly one check per unmigrated flake: none goes unchecked, and no
159
+ // check outlives the flake it was written for. A migrated job checks its
160
+ // own flake by running through it, so it is not covered here.
161
+ assertEq(job.steps.filter(step => step.run !== undefined).length, nodeNixJobs.length);
162
+ for (const { id } of nodeNixJobs) {
163
+ assert(hasRunInJob('nix-flakes', `nix develop ./nix/generated/${id} --command node --version`)(gha), `expected the ${id} flake to be instantiated`);
164
+ }
165
+ // Since the flakes no longer assert their own version, this job is the
166
+ // only place the Nix runtime is tied to the version `setup-node` installs.
167
+ for (const version of [node.node22, node.node24, node.default]) {
168
+ assert(hasRunInJob('nix-flakes', `= v${version}`)(gha), `expected the flake job to check Node ${version}`);
169
+ }
170
+ // The canonical Node jobs keep their current runtime setup until they
171
+ // are migrated one at a time.
172
+ for (const { id } of nodeNixJobs) {
173
+ assert(!hasRunInJob(id, 'nix develop')(gha), `unexpected nix develop in ${id}`);
174
+ }
175
+ },
138
176
  ubuntu: () => {
139
177
  const job = ubuntu([test({ run: 'echo hi' })]);
140
178
  assert(job['runs-on'] !== undefined, 'expected runs-on');
@@ -42,4 +42,5 @@ export type LoadModuleOperations = Access | Import | All | Readdir;
42
42
  export declare const loadModuleMap: (env: Env) => Effect<LoadModuleOperations, ModuleMap>;
43
43
  export declare const proof: {
44
44
  isSourceFile: () => void;
45
+ allFilesSkipsNodeModules: () => void;
45
46
  };
@@ -8,7 +8,8 @@ import { cmp as strCmp } from '../types/string/module.f.js';
8
8
  import { unwrap } from '../types/result/module.f.js';
9
9
  import { pure, step } from '../effects/module.f.js';
10
10
  import { join, relativize, toPosix } from '../path/module.f.js';
11
- import { assert } from '../asserts/module.f.js';
11
+ import { assert, assertEq } from '../asserts/module.f.js';
12
+ import { emptyState, virtual } from '../effects/node/virtual/module.f.js';
12
13
  /**
13
14
  * Returns `true` if the file should be loaded for proof discovery.
14
15
  *
@@ -72,7 +73,7 @@ export const loadModuleMap = (env) => {
72
73
  const s = initCwd === undefined ? '.' : toPosix(initCwd);
73
74
  const prefix = s === '.' ? '' : s;
74
75
  // TODO: there are multiple `all` effects here,
75
- // we should consider optimize them by ALIQ technique or something similar.
76
+ // we should consider optimizing them by ALIQ technique or something similar.
76
77
  // For example, we should be able to write it like `allFiles(s).flatMap(loadFile)`,
77
78
  // then an effect runner can batch all file loading operations together.
78
79
  const x0 = step(allFiles(s, shouldLoad), files => all(...files.map(loadFile)));
@@ -90,4 +91,14 @@ export const proof = {
90
91
  assert(!isSourceFile('readme.md'));
91
92
  assert(!isSourceFile('module.json'));
92
93
  },
94
+ allFilesSkipsNodeModules: () => {
95
+ // `node_modules` is skipped without descending into it, even though
96
+ // it contains a file that would otherwise match the predicate.
97
+ const root = {
98
+ 'node_modules': { 'pkg.f.ts': [] },
99
+ 'a.f.ts': [],
100
+ };
101
+ const [, result] = virtual({ ...emptyState, root })(allFiles('.', shouldLoad));
102
+ assertEq(result.join(','), './a.f.ts');
103
+ },
93
104
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Synchronizes local development configuration generated from canonical repository files.
3
+ *
4
+ * @module
5
+ */
6
+ import { type Effect } from '../../effects/module.f.ts';
7
+ import { type Mkdir, type NodeProgram, type ReadFile, type WriteFile } from '../../effects/node/module.f.ts';
8
+ /** Regenerates VS Code's local MCP configuration from the canonical Copilot configuration. */
9
+ export declare const syncMcp: () => Effect<Mkdir | ReadFile | WriteFile, void>;
10
+ /** Runs all local development configuration generators. */
11
+ export declare const main: NodeProgram;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Synchronizes local development configuration generated from canonical repository files.
3
+ *
4
+ * @module
5
+ */
6
+ import { history, historyStep, mapStep, step } from '../../effects/module.f.js';
7
+ import { mkdir, readUtf8File, writeUtf8File } from '../../effects/node/module.f.js';
8
+ import { unwrap } from '../../types/result/module.f.js';
9
+ const source = '.copilot/mcp.json';
10
+ const targetDirectory = '.vscode';
11
+ const target = '.vscode/mcp.json';
12
+ /** Regenerates VS Code's local MCP configuration from the canonical Copilot configuration. */
13
+ export const syncMcp = () => {
14
+ const sourceText = history(mapStep(readUtf8File(source), unwrap));
15
+ const targetDirectoryReady = historyStep(sourceText, () => mapStep(mkdir(targetDirectory, { recursive: true }), unwrap));
16
+ const targetWritten = step(targetDirectoryReady, ([, text]) => writeUtf8File(target, text));
17
+ return mapStep(targetWritten, unwrap);
18
+ };
19
+ /** Runs all local development configuration generators. */
20
+ export const main = () => mapStep(syncMcp(), () => 0);
@@ -0,0 +1,7 @@
1
+ export declare const proof: {
2
+ syncMcp: () => void;
3
+ main: () => void;
4
+ throw: {
5
+ missingSource: () => readonly [import("../../effects/node/virtual/module.f.ts").State, void];
6
+ };
7
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Proofs for local development configuration generation.
3
+ *
4
+ * @module
5
+ */
6
+ import { assert, assertEq } from '../../asserts/module.f.js';
7
+ import { utf8 } from '../../text/module.f.js';
8
+ import { readUtf8File } from '../../effects/node/module.f.js';
9
+ import { defaultNodeProgramOptions, emptyState, virtual } from '../../effects/node/virtual/module.f.js';
10
+ import { main, syncMcp } from './module.f.js';
11
+ import { step } from '../../effects/module.f.js';
12
+ const mcp = '{"servers":{}}';
13
+ const initial = {
14
+ ...emptyState,
15
+ root: {
16
+ '.copilot': {
17
+ 'mcp.json': [utf8(mcp)],
18
+ },
19
+ },
20
+ };
21
+ export const proof = {
22
+ syncMcp: () => {
23
+ const generatedMcp = step(syncMcp(), () => readUtf8File('.vscode/mcp.json'));
24
+ const [, [tag, result]] = virtual(initial)(generatedMcp);
25
+ assert(tag === 'ok', result);
26
+ assertEq(result, mcp);
27
+ },
28
+ main: () => {
29
+ const [, result] = virtual(initial)(main(defaultNodeProgramOptions));
30
+ assertEq(result, 0);
31
+ },
32
+ throw: {
33
+ missingSource: () => virtual(emptyState)(syncMcp()),
34
+ },
35
+ };
@@ -4,12 +4,59 @@
4
4
  * @module
5
5
  */
6
6
  import type { Primitive, Array, Unknown } from '../module.f.ts';
7
+ /**
8
+ * A parsed DJS module: its imported module specifiers, in source order, and
9
+ * its body.
10
+ *
11
+ * The specifier list indexes `['aref', i]`.
12
+ */
7
13
  export type AstModule = [readonly string[], AstBody];
14
+ /** A value in a module body: a primitive, a reference, an array, or an object. */
8
15
  export type AstConst = Primitive | AstModuleRef | AstArray | AstObject;
16
+ /**
17
+ * A reference to a value defined outside this `AstConst`.
18
+ *
19
+ * - `['aref', i]` — the `i`-th argument of the body, i.e. the `i`-th imported
20
+ * module of the enclosing `AstModule`.
21
+ * - `['cref', i]` — the `i`-th entry of the enclosing `AstBody`.
22
+ *
23
+ * Both indices are absolute and zero-based, **not** offsets from the
24
+ * referencing entry: in the body `[a, b, ['cref', 0]]` the reference resolves
25
+ * to `a`, not to the nearest preceding entry `b`.
26
+ *
27
+ * A `cref` index must be smaller than the index of the entry holding it —
28
+ * `run` evaluates a body left to right, so a reference to the current or a
29
+ * later entry is unsatisfiable. It is not rejected: it resolves to the most
30
+ * recently evaluated entry instead.
31
+ */
9
32
  export type AstModuleRef = ['aref' | 'cref', number];
33
+ /** An array value; its elements are evaluated in order. */
10
34
  export type AstArray = ['array', readonly AstConst[]];
35
+ /** An object value, keyed by property name. */
11
36
  export type AstObject = {
12
37
  readonly [k in string]?: AstConst;
13
38
  };
39
+ /**
40
+ * The constants of a module body, in declaration order. The **last** entry is
41
+ * the value the module yields; the preceding entries exist to be named by
42
+ * `['cref', i]`.
43
+ *
44
+ * A body describes the function
45
+ *
46
+ * ```js
47
+ * (...args) => { const c0 = ...; const c1 = ...; return <last> }
48
+ * ```
49
+ *
50
+ * where `args` are the imported modules.
51
+ */
14
52
  export type AstBody = readonly AstConst[];
53
+ /**
54
+ * Evaluates a module body against its imported modules and returns the value
55
+ * the module yields — the last entry of the body.
56
+ *
57
+ * Entries are evaluated left to right, so a `cref` resolves to an already
58
+ * evaluated entry. A reference is shared, not copied: two properties holding
59
+ * the same `['cref', i]` deserialize to the same object, which is what lets a
60
+ * DJS module denote a graph rather than a tree.
61
+ */
15
62
  export declare const run: (body: AstBody) => (args: Array) => Unknown;
@@ -42,6 +42,15 @@ const toDjs = state => ast => {
42
42
  }
43
43
  }
44
44
  };
45
+ /**
46
+ * Evaluates a module body against its imported modules and returns the value
47
+ * the module yields — the last entry of the body.
48
+ *
49
+ * Entries are evaluated left to right, so a `cref` resolves to an already
50
+ * evaluated entry. A reference is shared, not copied: two properties holding
51
+ * the same `['cref', i]` deserialize to the same object, which is what lets a
52
+ * DJS module denote a graph rather than a tree.
53
+ */
45
54
  export const run = body => args => {
46
55
  const state = fold(foldOp)({ body, args, consts: null })(body);
47
56
  return last(null)(state.consts);
@@ -364,7 +364,7 @@ export const tokenizeString = s => {
364
364
  }
365
365
  const m = descentParser(jsGrammar());
366
366
  const cpm = codePointsWithMetadata('')(cp);
367
- const [ast, ok, len] = m('', cpm);
367
+ const { ast, success: ok, idx: len } = m('', cpm);
368
368
  if (!ok || len !== cp.length)
369
369
  return 'error';
370
370
  const flatTokens = toArray(getTokensFromAstRule(ast));
@@ -394,7 +394,7 @@ export const tokenizeJs = input => path => {
394
394
  return [{ token: { kind: 'eof' }, metadata: initial }];
395
395
  const m = descentParser(jsGrammar());
396
396
  const cpm = codePointsWithMetadata(path)(cp);
397
- const [ast, ok, len] = m('', cpm);
397
+ const { ast, success: ok, idx: len } = m('', cpm);
398
398
  const finalMetadata = fold(advanceMetadata)(initial)(cp);
399
399
  if (!ok || len !== cp.length) {
400
400
  const errorMetadata = len < cpm.length ? cpm[len][1] : finalMetadata;
@@ -8,6 +8,7 @@ export declare const proof: {
8
8
  keywords: (() => void)[];
9
9
  comments: (() => void)[];
10
10
  metadata: (() => void)[];
11
+ errorPosition: (() => void)[];
11
12
  djsTokenize: (() => void)[];
12
13
  largeInputs: (() => void)[];
13
14
  };
@@ -8,13 +8,21 @@ import { sort } from '../../types/object/module.f.js';
8
8
  // DjsTokenWithMetadata carries bigint fields (bf, bigint value) that JSON.stringify can't
9
9
  // serialize — reuse the same djs stringifyAsTree serializer tokenizeString uses internally.
10
10
  const stringify = stringifyAsTree(sort);
11
+ // 'line:column' of the error token `s` tokenizes to, or 'no error'. Collapsing the
12
+ // position to one string keeps an expectation readable as the caret a reader would
13
+ // draw, instead of two separate assertions per case.
14
+ const errorAt = (s) => {
15
+ const found = toArray(tokenizeJs(stringToList(s))('a.js'))
16
+ .find(t => t.token.kind === 'error');
17
+ return found === undefined ? 'no error' : `${found.metadata.line}:${found.metadata.column}`;
18
+ };
11
19
  export const proof = {
12
20
  isValid: [() => {
13
21
  const m = descentParser(jsGrammar());
14
22
  const expect = (s, expected) => {
15
23
  const cp = toArray(stringToCodePointList(s));
16
24
  const mr = descentParserCpOnly(m, '', cp);
17
- const success = mr[1] && mr[2] === cp.length;
25
+ const success = mr.success && mr.idx === cp.length;
18
26
  assertEq(success, expected, JSON.stringify([s, mr]));
19
27
  };
20
28
  expect('"', false);
@@ -51,7 +59,7 @@ export const proof = {
51
59
  const m = descentParser(jsGrammar());
52
60
  const cp = toArray(stringToCodePointList('tr'));
53
61
  const mr = descentParserCpOnly(m, '', cp);
54
- const seq = mr[0].sequence[0];
62
+ const seq = mr.ast.sequence[0];
55
63
  assert(!(seq instanceof Array), JSON.stringify(mr));
56
64
  assertEq(seq.tag, 'id', JSON.stringify(mr));
57
65
  },
@@ -59,7 +67,7 @@ export const proof = {
59
67
  const m = descentParser(jsGrammar());
60
68
  const cp = toArray(stringToCodePointList('"tr"'));
61
69
  const mr = descentParserCpOnly(m, '', cp);
62
- const seq = mr[0].sequence[0];
70
+ const seq = mr.ast.sequence[0];
63
71
  assert(!(seq instanceof Array), JSON.stringify(mr));
64
72
  assertEq(seq.tag, 'string', JSON.stringify(mr));
65
73
  },
@@ -67,7 +75,7 @@ export const proof = {
67
75
  const m = descentParser(jsGrammar());
68
76
  const cp = toArray(stringToCodePointList('56.7e+5'));
69
77
  const mr = descentParserCpOnly(m, '', cp);
70
- const seq = mr[0].sequence[0];
78
+ const seq = mr.ast.sequence[0];
71
79
  assert(!(seq instanceof Array), JSON.stringify(mr));
72
80
  assertEq(seq.tag, 'number', JSON.stringify(mr));
73
81
  },
@@ -75,7 +83,7 @@ export const proof = {
75
83
  const m = descentParser(jsGrammar());
76
84
  const cp = toArray(stringToCodePointList('56n'));
77
85
  const mr = descentParserCpOnly(m, '', cp);
78
- const seq = mr[0].sequence[0];
86
+ const seq = mr.ast.sequence[0];
79
87
  assert(!(seq instanceof Array), JSON.stringify(mr));
80
88
  assertEq(seq.tag, 'number', JSON.stringify(mr));
81
89
  },
@@ -83,7 +91,7 @@ export const proof = {
83
91
  const m = descentParser(jsGrammar());
84
92
  const cp = toArray(stringToCodePointList('*'));
85
93
  const mr = descentParserCpOnly(m, '', cp);
86
- const seq = mr[0].sequence[0];
94
+ const seq = mr.ast.sequence[0];
87
95
  assert(!(seq instanceof Array), JSON.stringify(mr));
88
96
  assertEq(seq.tag, '*', JSON.stringify(mr));
89
97
  },
@@ -91,7 +99,7 @@ export const proof = {
91
99
  const m = descentParser(jsGrammar());
92
100
  const cp = toArray(stringToCodePointList('**'));
93
101
  const mr = descentParserCpOnly(m, '', cp);
94
- const seq = mr[0].sequence[0];
102
+ const seq = mr.ast.sequence[0];
95
103
  assert(!(seq instanceof Array), JSON.stringify(mr));
96
104
  assertEq(seq.tag, '**', JSON.stringify(mr));
97
105
  },
@@ -99,7 +107,7 @@ export const proof = {
99
107
  const m = descentParser(jsGrammar());
100
108
  const cp = toArray(stringToCodePointList('=>'));
101
109
  const mr = descentParserCpOnly(m, '', cp);
102
- const seq = mr[0].sequence[0];
110
+ const seq = mr.ast.sequence[0];
103
111
  assert(!(seq instanceof Array), JSON.stringify(mr));
104
112
  assertEq(seq.tag, '=>', JSON.stringify(mr));
105
113
  },
@@ -107,7 +115,7 @@ export const proof = {
107
115
  const m = descentParser(jsGrammar());
108
116
  const cp = toArray(stringToCodePointList('=='));
109
117
  const mr = descentParserCpOnly(m, '', cp);
110
- const seq = mr[0].sequence[0];
118
+ const seq = mr.ast.sequence[0];
111
119
  assert(!(seq instanceof Array), JSON.stringify(mr));
112
120
  assert(!(seq.tag !== '=='), JSON.stringify(mr));
113
121
  },
@@ -115,7 +123,7 @@ export const proof = {
115
123
  const m = descentParser(jsGrammar());
116
124
  const cp = toArray(stringToCodePointList('==='));
117
125
  const mr = descentParserCpOnly(m, '', cp);
118
- const seq = mr[0].sequence[0];
126
+ const seq = mr.ast.sequence[0];
119
127
  assert(!(seq instanceof Array), JSON.stringify(mr));
120
128
  assert(!(seq.tag !== '==='), JSON.stringify(mr));
121
129
  },
@@ -123,7 +131,7 @@ export const proof = {
123
131
  const m = descentParser(jsGrammar());
124
132
  const cp = toArray(stringToCodePointList('='));
125
133
  const mr = descentParserCpOnly(m, '', cp);
126
- const seq = mr[0].sequence[0];
134
+ const seq = mr.ast.sequence[0];
127
135
  assert(!(seq instanceof Array), JSON.stringify(mr));
128
136
  assertEq(seq.tag, '=', JSON.stringify(mr));
129
137
  },
@@ -131,7 +139,7 @@ export const proof = {
131
139
  const m = descentParser(jsGrammar());
132
140
  const cp = toArray(stringToCodePointList(' '));
133
141
  const mr = descentParserCpOnly(m, '', cp);
134
- const seq = mr[0].sequence[0];
142
+ const seq = mr.ast.sequence[0];
135
143
  assert(!(seq instanceof Array), JSON.stringify(mr));
136
144
  assertEq(seq.tag, ' ', JSON.stringify(mr));
137
145
  },
@@ -139,7 +147,7 @@ export const proof = {
139
147
  const m = descentParser(jsGrammar());
140
148
  const cp = toArray(stringToCodePointList('\n'));
141
149
  const mr = descentParserCpOnly(m, '', cp);
142
- const seq = mr[0].sequence[0];
150
+ const seq = mr.ast.sequence[0];
143
151
  assert(!(seq instanceof Array), JSON.stringify(mr));
144
152
  assertEq(seq.tag, '\n', JSON.stringify(mr));
145
153
  },
@@ -147,7 +155,7 @@ export const proof = {
147
155
  const m = descentParser(jsGrammar());
148
156
  const cp = toArray(stringToCodePointList('/\n'));
149
157
  const mr = descentParserCpOnly(m, '', cp);
150
- const seq = mr[0].sequence[0];
158
+ const seq = mr.ast.sequence[0];
151
159
  assert(!(seq instanceof Array), JSON.stringify(mr));
152
160
  assertEq(seq.tag, '/', JSON.stringify(mr));
153
161
  },
@@ -155,7 +163,7 @@ export const proof = {
155
163
  const m = descentParser(jsGrammar());
156
164
  const cp = toArray(stringToCodePointList('//\n'));
157
165
  const mr = descentParserCpOnly(m, '', cp);
158
- const seq = mr[0].sequence[0];
166
+ const seq = mr.ast.sequence[0];
159
167
  assert(!(seq instanceof Array), JSON.stringify(mr));
160
168
  assertEq(seq.tag, 'comment', JSON.stringify(mr));
161
169
  },
@@ -163,7 +171,7 @@ export const proof = {
163
171
  const m = descentParser(jsGrammar());
164
172
  const cp = toArray(stringToCodePointList('/*1*/'));
165
173
  const mr = descentParserCpOnly(m, '', cp);
166
- const seq = mr[0].sequence[0];
174
+ const seq = mr.ast.sequence[0];
167
175
  assert(!(seq instanceof Array), JSON.stringify(mr));
168
176
  assertEq(seq.tag, 'comment', JSON.stringify(mr));
169
177
  }
@@ -1056,6 +1064,52 @@ export const proof = {
1056
1064
  assertEq(JSON.stringify(result), '[{"token":{"kind":"error","message":"invalid token"},"metadata":{"path":"a.js","line":1,"column":2}}]');
1057
1065
  },
1058
1066
  ],
1067
+ // Where an error token is reported. The `tokenizer` group above checks that
1068
+ // these inputs are rejected at all, through the metadata-free
1069
+ // `tokenizeString`; here the same shapes go through `tokenizeJs` so the
1070
+ // position is pinned too.
1071
+ errorPosition: [
1072
+ () => {
1073
+ // input that tokenizes cleanly produces no error token at all —
1074
+ // without this the helper's own no-error branch never runs
1075
+ assertEq(errorAt('x'), 'no error');
1076
+ assertEq(errorAt('{ "a": 1 }'), 'no error');
1077
+ },
1078
+ () => {
1079
+ // a character no rule accepts: reported where it stands
1080
+ assertEq(errorAt('ᄑ'), '1:1');
1081
+ },
1082
+ () => {
1083
+ // after a good prefix, the position advances past it
1084
+ assertEq(errorAt('x @'), '1:3');
1085
+ },
1086
+ () => {
1087
+ // line as well as column, once newlines have been consumed
1088
+ assertEq(errorAt('a\nb\n@'), '3:1');
1089
+ assertEq(errorAt('x\n\n @y'), '3:3');
1090
+ },
1091
+ () => {
1092
+ // a number poisoned by a trailing character points at that character
1093
+ assertEq(errorAt('00'), '1:2');
1094
+ },
1095
+ () => {
1096
+ // a number cut short points just past the input
1097
+ assertEq(errorAt('1.'), '1:3');
1098
+ },
1099
+ () => {
1100
+ // Unterminated tokens anchor at the token's *start*, not where the
1101
+ // input ran out: the grammar matches them and tags them
1102
+ // 'unterminated', so this is the structural-error path, not a failed
1103
+ // match. See ./todo/error-position-range.md.
1104
+ assertEq(errorAt('"value'), '1:1');
1105
+ assertEq(errorAt('"a\nb"'), '1:1');
1106
+ },
1107
+ () => {
1108
+ // an unterminated block comment is the exception: its content is
1109
+ // consumed, so the report lands at the end of input
1110
+ assertEq(errorAt('/* c'), '1:5');
1111
+ },
1112
+ ],
1059
1113
  // DJS-level: keyword remapping and '-'-folding on top of tokenizeJs. Doesn't re-test
1060
1114
  // JS-level token shapes already covered above/elsewhere in this file.
1061
1115
  djsTokenize: [
@@ -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
  };
@@ -4,9 +4,9 @@ import { type NodeProgram } from './module.f.ts';
4
4
  * resolving to its exit code **without** terminating the process.
5
5
  *
6
6
  * Use this when the caller must stay alive afterwards — e.g. when proofs are
7
- * registered under an external test runner (Node `--test`, Bun, Playwright)
8
- * that owns the process lifecycle. For a standalone CLI entry point that should
9
- * exit with the program's code, use {@link run} instead.
7
+ * registered under an external test runner (Node `--test`, Bun, Deno) that owns
8
+ * the process lifecycle. For a standalone CLI entry point that should exit with
9
+ * the program's code, use {@link run} instead.
10
10
  */
11
11
  export declare const runEffect: (p: NodeProgram) => Promise<number>;
12
12
  /**