@cotal-ai/lang 0.23.0 → 0.24.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 (57) hide show
  1. package/README.md +57 -0
  2. package/dist/effects.d.ts +19 -0
  3. package/dist/effects.d.ts.map +1 -1
  4. package/dist/effects.js +23 -0
  5. package/dist/effects.js.map +1 -1
  6. package/dist/errors.d.ts +48 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +90 -2
  9. package/dist/errors.js.map +1 -1
  10. package/dist/grammar.d.ts.map +1 -1
  11. package/dist/grammar.js +561 -128
  12. package/dist/grammar.js.map +1 -1
  13. package/dist/index.d.ts +10 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +10 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/interpret.d.ts +81 -3
  18. package/dist/interpret.d.ts.map +1 -1
  19. package/dist/interpret.js +1446 -270
  20. package/dist/interpret.js.map +1 -1
  21. package/dist/journal.d.ts +218 -9
  22. package/dist/journal.d.ts.map +1 -1
  23. package/dist/journal.js +232 -12
  24. package/dist/journal.js.map +1 -1
  25. package/dist/keys.d.ts +39 -1
  26. package/dist/keys.d.ts.map +1 -1
  27. package/dist/keys.js +61 -0
  28. package/dist/keys.js.map +1 -1
  29. package/dist/library.d.ts +65 -0
  30. package/dist/library.d.ts.map +1 -0
  31. package/dist/library.js +525 -0
  32. package/dist/library.js.map +1 -0
  33. package/dist/notify-fact.d.ts +8 -0
  34. package/dist/notify-fact.d.ts.map +1 -0
  35. package/dist/notify-fact.js +69 -0
  36. package/dist/notify-fact.js.map +1 -0
  37. package/dist/pins.d.ts +82 -0
  38. package/dist/pins.d.ts.map +1 -0
  39. package/dist/pins.js +87 -0
  40. package/dist/pins.js.map +1 -0
  41. package/dist/primitives.d.ts +14 -0
  42. package/dist/primitives.d.ts.map +1 -1
  43. package/dist/primitives.js +45 -5
  44. package/dist/primitives.js.map +1 -1
  45. package/dist/sim.d.ts +9 -1
  46. package/dist/sim.d.ts.map +1 -1
  47. package/dist/sim.js +9 -1
  48. package/dist/sim.js.map +1 -1
  49. package/dist/syntax.d.ts +34 -0
  50. package/dist/syntax.d.ts.map +1 -0
  51. package/dist/syntax.js +178 -0
  52. package/dist/syntax.js.map +1 -0
  53. package/dist/values.d.ts +20 -1
  54. package/dist/values.d.ts.map +1 -1
  55. package/dist/values.js +0 -0
  56. package/dist/values.js.map +1 -1
  57. package/package.json +2 -2
package/dist/grammar.js CHANGED
@@ -12,7 +12,10 @@
12
12
  */
13
13
  import { parse } from "acorn";
14
14
  import { LangError, LangErrors } from "./errors.js";
15
- import { BUILTINS, FORBIDDEN_GLOBALS, NOTIFY_BOUND, PRIMITIVES, PROMISE_NAMES, RESERVED_NAMES, STEP_NAME_RE, primitiveDoc, } from "./primitives.js";
15
+ import { BUILTINS, FORBIDDEN_GLOBALS, HOST_GLOBAL_HINTS, NOTIFY_BOUND, PRIMITIVES, PROMISE_NAMES, RESERVED_NAMES, STEP_NAME_RE, primitiveDoc, } from "./primitives.js";
16
+ import { KEY_RESERVED_RE } from "./keys.js";
17
+ import { ADMITTED_NODES, FORBIDDEN_NODES, STRUCTURAL_NODES } from "./syntax.js";
18
+ import { MUTATING_METHODS } from "./library.js";
16
19
  const ACORN_OPTIONS = {
17
20
  ecmaVersion: 2023,
18
21
  sourceType: "module",
@@ -27,6 +30,24 @@ class Validator {
27
30
  file;
28
31
  errors = [];
29
32
  warnings = [];
33
+ /**
34
+ * Named functions, by name, for L2032's reach.
35
+ *
36
+ * A branch does not have to be written at the combinator call — `parallel({ a, b })` names two
37
+ * functions declared elsewhere, and an inline branch can call a helper that writes the outer
38
+ * binding on its behalf. Both are the same defect and neither is visible from the call site
39
+ * alone, so the names are resolved here. A name bound to two different functions maps to `null`:
40
+ * it cannot be resolved, and guessing which one a branch meant is worse than saying so.
41
+ */
42
+ functions = new Map();
43
+ /**
44
+ * The parent of every call, recorded by the shape walk for the resolution walk.
45
+ *
46
+ * L2013 is a rule about POSITION (awaited, returned, or a combinator's thunk) and, for a user
47
+ * function, about the callee's declaration (async or not). The shape walk sees the position and
48
+ * the resolution walk sees the declaration, so the position is carried across.
49
+ */
50
+ parents = new WeakMap();
30
51
  constructor(source, file) {
31
52
  this.source = source;
32
53
  this.file = file;
@@ -53,88 +74,16 @@ class Validator {
53
74
  }
54
75
  }
55
76
  // ---- walk 1: shape ------------------------------------------------------------------------
56
- /** Node types rejected outright, with the code and the repair to suggest. */
57
- const FORBIDDEN_NODES = Object.freeze({
58
- ClassDeclaration: {
59
- code: "L1001",
60
- cause: "There are no classes in this language. State lives in records and behaviour lives in functions.",
61
- fix: "Replace the class with a function that returns a record.",
62
- },
63
- ClassExpression: {
64
- code: "L1001",
65
- cause: "There are no classes in this language. State lives in records and behaviour lives in functions.",
66
- fix: "Replace the class with a function that returns a record.",
67
- },
68
- ThisExpression: {
69
- code: "L1002",
70
- cause: "`this` does not exist, so nothing can capture a calling context by accident.",
71
- fix: "Pass what the function needs as an argument.",
72
- },
73
- ForInStatement: {
74
- code: "L1004",
75
- cause: "`for...in` walks an unspecified order and reaches inherited names, so it cannot be deterministic.",
76
- fix: "Iterate explicitly: `for (const k of keys(record)) { ... }`.",
77
- },
78
- WithStatement: {
79
- code: "L1013",
80
- cause: "`with` makes name resolution dynamic, and every name here resolves at parse time.",
81
- fix: "Reference the record's fields directly.",
82
- },
83
- TaggedTemplateExpression: {
84
- code: "L1018",
85
- cause: "A tagged template runs user code during evaluation of a literal, which hides an effect inside what looks like data.",
86
- fix: "Use a plain template literal, or call the function explicitly.",
87
- },
88
- NewExpression: {
89
- code: "L1019",
90
- cause: "There are no constructors, so `new` has nothing to construct.",
91
- fix: "Build a record literal, or call a function that returns one.",
92
- },
93
- ImportDeclaration: {
94
- code: "L1020",
95
- cause: "A program is exactly one module, because a run pins to the content hash of its source.",
96
- fix: "Define the function in this file. Shared procedures are ordinary functions.",
97
- },
98
- ExportNamedDeclaration: {
99
- code: "L1020",
100
- cause: "A program is exactly one module and has nothing to export to.",
101
- fix: "Remove the `export`.",
102
- },
103
- ExportDefaultDeclaration: {
104
- code: "L1020",
105
- cause: "A program is exactly one module and has nothing to export to.",
106
- fix: "Remove the `export`.",
107
- },
108
- ExportAllDeclaration: {
109
- code: "L1020",
110
- cause: "A program is exactly one module and has nothing to export to.",
111
- fix: "Remove the `export`.",
112
- },
113
- DoWhileStatement: {
114
- code: "L1022",
115
- cause: "`do...while` is not in the language.",
116
- fix: "Use `while` with the condition checked first, or a `for` loop.",
117
- },
118
- LabeledStatement: {
119
- code: "L1017",
120
- cause: "Labels turn the derived flowchart's back-edges into arbitrary jumps.",
121
- fix: "Restructure with a helper function or a boolean flag.",
122
- },
123
- BreakStatement: {
124
- code: "L1017",
125
- cause: "A labelled break is an arbitrary jump.",
126
- fix: "Restructure with a helper function or a boolean flag.",
127
- },
128
- ContinueStatement: {
129
- code: "L1017",
130
- cause: "A labelled continue is an arbitrary jump.",
131
- fix: "Restructure with a helper function or a boolean flag.",
132
- },
133
- AwaitExpression: {
134
- code: "L1023",
135
- cause: "This `await` sits inside a function that is not `async`. Every effect is awaited, so a function that performs one is async.",
136
- fix: "Mark the enclosing function `async`: `async function name(...) { ... }`.",
137
- },
77
+ /** The two conditional refusals the table cannot carry: a LABELLED jump and an `await` outside async. */
78
+ const LABELLED_JUMP = Object.freeze({
79
+ code: "L1017",
80
+ cause: "A labelled break or continue is an arbitrary jump.",
81
+ fix: "Restructure with a helper function or a boolean flag.",
82
+ });
83
+ const AWAIT_OUTSIDE_ASYNC = Object.freeze({
84
+ code: "L1023",
85
+ cause: "This `await` sits inside a function that is not `async`. Every effect is awaited, so a function that performs one is async.",
86
+ fix: "Mark the enclosing function `async`: `async function name(...) { ... }`.",
138
87
  });
139
88
  /**
140
89
  * Automatic semicolon insertion is ALLOWED, against Jessie, and this is a declared deviation.
@@ -196,6 +145,13 @@ const PARSE_ERROR_MAP = [
196
145
  cause: "A program has no return value. Its outcome is what it did: the journal of its effects, and whatever it published onto the run record. There is nobody for a top-level `return` to return to.",
197
146
  fix: "Publish the result onto the run record, or use `log(...)` if you only wanted it in the trace.",
198
147
  },
148
+ {
149
+ // A program is a module, so it is strict, and acorn refuses `with` before any walk sees it.
150
+ test: /'with' in strict mode/i,
151
+ code: "L1013",
152
+ cause: "`with` makes name resolution dynamic, and every name here resolves at parse time.",
153
+ fix: "Reference the record's fields directly.",
154
+ },
199
155
  {
200
156
  test: /keyword 'await' outside an async function|await is only valid in async/i,
201
157
  code: "L1023",
@@ -225,34 +181,83 @@ function children(node) {
225
181
  function isNode(v) {
226
182
  return v !== null && typeof v === "object" && typeof v.type === "string";
227
183
  }
228
- function walkShape(node, v, inAsync, parent = null) {
184
+ /** True when a statement always leaves its `switch` case: a terminator, or a block/if made of them. */
185
+ function terminates(stmt) {
186
+ switch (stmt.type) {
187
+ case "ReturnStatement":
188
+ case "BreakStatement":
189
+ case "ContinueStatement":
190
+ case "ThrowStatement":
191
+ return true;
192
+ case "BlockStatement": {
193
+ const body = stmt.body ?? [];
194
+ const last = body[body.length - 1];
195
+ return last !== undefined && terminates(last);
196
+ }
197
+ case "IfStatement":
198
+ return (isNode(stmt.consequent) &&
199
+ terminates(stmt.consequent) &&
200
+ isNode(stmt.alternate) &&
201
+ terminates(stmt.alternate));
202
+ default:
203
+ return false;
204
+ }
205
+ }
206
+ /** The static name of a member access: `x.a` and `x["a"]` both name `a`; `x[k]` names nothing. */
207
+ function memberName(member) {
208
+ const property = member.property;
209
+ if (property === undefined)
210
+ return null;
211
+ if (member.computed !== true)
212
+ return property.type === "Identifier" ? property.name : null;
213
+ return property.type === "Literal" && typeof property.value === "string" ? property.value : null;
214
+ }
215
+ /** The name of a non-computed property key, or null when it is computed. */
216
+ function propertyKeyName(node) {
217
+ if (node.computed === true)
218
+ return null;
219
+ const key = node.key;
220
+ if (key === undefined)
221
+ return null;
222
+ if (key.type === "Identifier")
223
+ return key.name;
224
+ if (key.type === "Literal")
225
+ return String(key.value);
226
+ return null;
227
+ }
228
+ function walkShape(node, v, inAsync, parent = null,
229
+ /** An ancestor already carried a forbidden row, so this node's own presence needs no second error. */
230
+ underForbidden = false) {
229
231
  const type = node.type;
230
232
  // Labels and labelled jumps: a bare break/continue is fine, a labelled one is not.
231
- if (type === "BreakStatement" || type === "ContinueStatement") {
232
- if (node.label !== null && node.label !== undefined) {
233
- const r = FORBIDDEN_NODES[type];
234
- if (r !== undefined)
235
- v.fail(r.code, node, r.cause, r.fix);
236
- }
233
+ if ((type === "BreakStatement" || type === "ContinueStatement") && node.label !== null && node.label !== undefined) {
234
+ v.fail(LABELLED_JUMP.code, node, LABELLED_JUMP.cause, LABELLED_JUMP.fix);
237
235
  return;
238
236
  }
239
237
  // `await` is legal only inside an async function.
240
238
  if (type === "AwaitExpression" && !inAsync) {
241
- const r = FORBIDDEN_NODES.AwaitExpression;
242
- if (r !== undefined)
243
- v.fail(r.code, node, r.cause, r.fix);
239
+ v.fail(AWAIT_OUTSIDE_ASYNC.code, node, AWAIT_OUTSIDE_ASYNC.cause, AWAIT_OUTSIDE_ASYNC.fix);
244
240
  }
245
- const rule = type === "AwaitExpression" ? undefined : FORBIDDEN_NODES[type];
241
+ // THE TABLE. A node is admitted, structural, on a forbidden row, or outside the language.
242
+ const rule = FORBIDDEN_NODES[type];
243
+ let forbidden = underForbidden;
246
244
  if (rule !== undefined) {
247
245
  v.fail(rule.code, node, rule.cause, rule.fix);
246
+ forbidden = true;
247
+ }
248
+ else if (!ADMITTED_NODES.has(type) && !STRUCTURAL_NODES.has(type) && !underForbidden) {
249
+ v.fail("L1029", node, `\`${type}\` is valid JavaScript but is not in this language, which is a fixed subset of it.`, "Rewrite with the constructs the language has: functions, records, arrays, loops, conditionals, try/catch, and the effect primitives.");
250
+ forbidden = true;
248
251
  }
249
252
  if (type === "Program" || type === "BlockStatement")
250
253
  checkAsiHazards(node, v);
251
254
  if (type === "CallExpression" || (type === "MemberExpression" && node.computed === true)) {
252
255
  checkContinuationHazard(node, v);
253
256
  }
254
- if (type === "CallExpression")
257
+ if (type === "CallExpression") {
258
+ v.parents.set(node, parent);
255
259
  checkAsyncCallPosition(node, parent, v);
260
+ }
256
261
  switch (type) {
257
262
  case "VariableDeclaration":
258
263
  if (node.kind === "var") {
@@ -270,6 +275,9 @@ function walkShape(node, v, inAsync, parent = null) {
270
275
  if (node.regex !== undefined) {
271
276
  v.fail("L1007", node, "There are no regular expressions, so a program cannot spend unbounded time in a match.", "Use `contains`, `startsWith`, `endsWith`, or `split`.");
272
277
  }
278
+ if (node.bigint !== undefined || typeof node.value === "bigint") {
279
+ v.fail("L1030", node, "Numbers here are IEEE doubles with a canonical JSON form; a bigint has neither, so it could not be journalled or cross an effect boundary.", "Use a number, or a string for an identifier that exceeds 2^53.");
280
+ }
273
281
  break;
274
282
  case "IfStatement": {
275
283
  for (const branch of ["consequent", "alternate"]) {
@@ -293,8 +301,10 @@ function walkShape(node, v, inAsync, parent = null) {
293
301
  const consequent = node.consequent;
294
302
  if (Array.isArray(consequent) && consequent.length > 0) {
295
303
  const last = consequent[consequent.length - 1];
296
- const terminators = ["ReturnStatement", "BreakStatement", "ContinueStatement", "ThrowStatement"];
297
- if (isNode(last) && !terminators.includes(last.type)) {
304
+ // The check looks THROUGH a braced case body: the language asks for blocks everywhere
305
+ // else, so `case 1: { ...; break; }` is the shape it invites, and refusing it read as a
306
+ // rule against the block rather than against the fall-through.
307
+ if (isNode(last) && !terminates(last)) {
298
308
  v.fail("L1010", node, "A case that falls through to the next one is nearly always a missing `break`.", "End the case with `return`, `break`, `continue`, or `throw`.");
299
309
  }
300
310
  }
@@ -302,7 +312,10 @@ function walkShape(node, v, inAsync, parent = null) {
302
312
  }
303
313
  case "Property":
304
314
  if (node.computed === true) {
305
- v.fail("L1011", node, "A computed key means the record's shape is not visible in the source, so neither the validator nor the flowchart can read it.", "Use a literal key, or build the record with `merge`.");
315
+ v.fail("L1011", node, "A computed key means the record's shape is not visible in the source, so neither the validator nor the flowchart can read it.", "Use a literal key, or build the record with `merge`, or write it as `record[key] = value`.");
316
+ }
317
+ if (parent?.type === "ObjectExpression" && propertyKeyName(node) === "__proto__") {
318
+ v.fail("L1028", node, "`__proto__` names an object's prototype, and there are no prototypes here: a record has exactly the fields written on it.", "Choose another field name.");
306
319
  }
307
320
  if (node.kind === "get" || node.kind === "set") {
308
321
  v.fail("L1015", node, "An accessor runs code when a property is read, which hides an effect behind what looks like data.", "Store the value, or call a function explicitly.");
@@ -314,6 +327,9 @@ function walkShape(node, v, inAsync, parent = null) {
314
327
  }
315
328
  break;
316
329
  case "BinaryExpression":
330
+ if (node.operator === "==" || node.operator === "!=") {
331
+ v.fail("L1025", node, `\`${node.operator}\` coerces its operands before comparing them, so \`0 == ""\` and \`null == undefined\` are true and a comparison's answer depends on rules nobody wrote down here.`, `Use \`${node.operator === "==" ? "===" : "!=="}\`, and \`?? \` or \`=== null\` when the question is about a missing value.`);
332
+ }
317
333
  if (node.operator === "instanceof") {
318
334
  v.fail("L1016", node, "There are no classes or prototypes, so `instanceof` can only probe host objects.", "Compare a field instead, for example `value.status === \"done\"`.");
319
335
  }
@@ -322,6 +338,9 @@ function walkShape(node, v, inAsync, parent = null) {
322
338
  }
323
339
  break;
324
340
  case "UnaryExpression":
341
+ if (node.operator === "void") {
342
+ v.fail("L1027", node, "`void` evaluates an expression and discards it, which is only ever used to hide a value or to spell `undefined` obscurely.", "Write `undefined` when you mean it, or drop the expression.");
343
+ }
325
344
  if (node.operator === "delete") {
326
345
  v.fail("L1021", node, "Records that cross an effect boundary are frozen, and deleting from a live one makes its shape depend on control flow.", "Build a new record with the fields you want.");
327
346
  }
@@ -333,17 +352,51 @@ function walkShape(node, v, inAsync, parent = null) {
333
352
  ? node.async === true
334
353
  : inAsync;
335
354
  for (const child of children(node))
336
- walkShape(child, v, nowAsync, node);
355
+ walkShape(child, v, nowAsync, node, forbidden);
337
356
  }
338
357
  // ---- walk 2: resolution and effect call shape --------------------------------------------
339
358
  class Scope {
340
359
  parent;
341
360
  names = new Map();
361
+ /** The function node a name is bound to HERE, when it is bound to one at all. */
362
+ fns = new Map();
363
+ /**
364
+ * Names declared LATER in this scope than the point the walk has reached. A `let`/`const` binds
365
+ * the WHOLE block it is in, so a reference above the declaration resolves to it — and, executed,
366
+ * would find a binding that does not hold a value yet (JavaScript's temporal dead zone, a
367
+ * guaranteed runtime ReferenceError in straight-line code). This language refuses it when the
368
+ * program is read (L2004), the way it already refuses an unknown name (L2001) that JavaScript
369
+ * would also only catch at runtime. A reference from inside a NESTED FUNCTION is exempt: the
370
+ * function runs later, when the binding may well be initialized — that is the mutual-recursion
371
+ * shape — so the walk only refuses what straight-line execution is certain to hit.
372
+ */
373
+ pending = new Set();
374
+ /** True where this scope is a function body: references from inside it to a pending outer name are deferred, not certain. */
375
+ fnBoundary = false;
342
376
  constructor(parent) {
343
377
  this.parent = parent;
344
378
  }
345
- declare(name, kind) {
379
+ declare(name, kind, fn) {
346
380
  this.names.set(name, kind);
381
+ if (fn !== undefined)
382
+ this.fns.set(name, fn);
383
+ else
384
+ this.fns.delete(name);
385
+ }
386
+ /**
387
+ * Is a reference to `name` FROM this scope certain to land in a dead zone? True only when the
388
+ * scope that owns the binding still has it pending and no function boundary lies between the
389
+ * reference and the owner.
390
+ */
391
+ refersToPending(name) {
392
+ let crossedFn = false;
393
+ for (let s = this; s !== null; s = s.parent) {
394
+ if (s.names.has(name))
395
+ return s.pending.has(name) && !crossedFn;
396
+ if (s.fnBoundary)
397
+ crossedFn = true;
398
+ }
399
+ return false;
347
400
  }
348
401
  lookup(name) {
349
402
  for (let s = this; s !== null; s = s.parent) {
@@ -353,6 +406,23 @@ class Scope {
353
406
  }
354
407
  return undefined;
355
408
  }
409
+ /**
410
+ * The function this name is bound to AT THIS POINT IN THE PROGRAM, or nothing.
411
+ *
412
+ * The nearest binding decides, and a binding that is not a function answers `undefined` rather
413
+ * than deferring outward — that is the difference between resolving a name and resolving a
414
+ * BINDING. A program-wide name map cannot tell `parallel({ branch })` inside
415
+ * `function use(branch)` from the top-level `function branch()` of the same name, and blaming a
416
+ * clean program for what an unrelated declaration elsewhere happens to write is worse than
417
+ * leaving one branch unproven: an unproven branch is still refused at runtime by the depth check.
418
+ */
419
+ lookupFn(name) {
420
+ for (let s = this; s !== null; s = s.parent) {
421
+ if (s.names.has(name))
422
+ return s.fns.get(name);
423
+ }
424
+ return undefined;
425
+ }
356
426
  }
357
427
  /** Collect the identifiers a binding pattern introduces. */
358
428
  function patternNames(node, out) {
@@ -514,31 +584,44 @@ function checkAsyncCallPosition(node, parent, v) {
514
584
  if (!isNode(callee) || callee.type !== "Identifier")
515
585
  return;
516
586
  const name = callee.name;
517
- // Primitives are always effects; a user function is only interesting if it was declared async,
518
- // which the resolution walk cannot know here, so both are treated the same way: the POSITION
519
- // is what is checked, not the callee's nature.
520
- const isEffect = PRIMITIVES[name] !== undefined;
521
- if (!isEffect)
587
+ // Primitives are always effects and are checked here, where the position is known. A user
588
+ // function is an effect only if it was declared async, which only the resolution walk can see:
589
+ // {@link checkCall} applies the same rule to those, reading the position back from `v.parents`.
590
+ if (PRIMITIVES[name] === undefined)
522
591
  return;
523
- if (parent === null)
592
+ if (parent === null || asyncCallPositionOk(parent))
524
593
  return;
525
- // Only two positions are legal: awaited, or the concise body of an arrow that a combinator
526
- // owns as a thunk. Everything else, including a bare statement, starts work nothing waits for.
527
- const ok = parent.type === "AwaitExpression" ||
594
+ v.fail("L2013", node, unawaitedCause(name), unawaitedFix(name), name);
595
+ }
596
+ /**
597
+ * Only two positions are legal for a call that starts an effect: awaited, or the concise body of
598
+ * an arrow that a combinator owns as a thunk (a `return` is the braced spelling of the same thing).
599
+ * Everything else, including a bare statement, starts work nothing waits for.
600
+ */
601
+ function asyncCallPositionOk(parent) {
602
+ return (parent.type === "AwaitExpression" ||
528
603
  parent.type === "ArrowFunctionExpression" ||
529
- parent.type === "ReturnStatement";
530
- if (ok)
531
- return;
532
- v.fail("L2013", node, `This \`${name}\` is not awaited, so it starts work whose result nothing waits for. Read literally the program says one thing and the runtime does another: calls outside a combinator run in sequence, not concurrently.`, `Await it (\`await ${name}(...)\`), return it, or make it a branch of \`parallel\`, \`race\` or \`fanOut\`.`, name);
604
+ parent.type === "ReturnStatement");
533
605
  }
534
- function checkCall(node, v) {
606
+ const unawaitedCause = (name) => `This \`${name}\` is not awaited, so it starts work whose result nothing waits for. Read literally the program says one thing and the runtime does another: calls outside a combinator run in sequence, not concurrently.`;
607
+ const unawaitedFix = (name) => `Await it (\`await ${name}(...)\`), return it, or make it a branch of \`parallel\`, \`race\` or \`fanOut\`.`;
608
+ function checkCall(node, v, scope) {
535
609
  const callee = node.callee;
536
610
  if (!isNode(callee) || callee.type !== "Identifier")
537
611
  return;
538
612
  const name = callee.name;
539
613
  const spec = PRIMITIVES[name];
540
- if (spec === undefined)
614
+ if (spec === undefined) {
615
+ // L2013's other half: a USER function declared `async` (or a const bound to an async function
616
+ // expression) is an effect the moment it is called, and holding its call in a binding is the
617
+ // rule's own motivating example: `const pa = work(a); const pb = work(b);`.
618
+ const fn = scope.lookupFn(name);
619
+ const parent = v.parents.get(node) ?? null;
620
+ if (fn !== undefined && fn.async === true && parent !== null && !asyncCallPositionOk(parent)) {
621
+ v.fail("L2013", node, unawaitedCause(name), unawaitedFix(name));
622
+ }
541
623
  return;
624
+ }
542
625
  const args = node.arguments ?? [];
543
626
  // `checkpoint` takes its name positionally; every other primitive takes it in the option bag.
544
627
  if (name === "checkpoint") {
@@ -573,16 +656,35 @@ function checkCall(node, v) {
573
656
  v.fail("L3011", prop, `\`${name}\` has no option named \`${key}\`, and option bags are closed so a typo cannot be silently dropped.`, `Accepted keys: ${spec.options.join(", ")}.`, name);
574
657
  }
575
658
  }
576
- // Required step names.
577
- if (spec.nameRequired && name !== "checkpoint") {
659
+ // Step names: REQUIRED-ness and VALIDITY are separate questions and must not share one gate.
660
+ //
661
+ // Required-ness is a statement about the caller's obligation; validity is a statement about the
662
+ // sink. `nameRequired` is false for 7 of the 13 primitives, so behind one gate a name supplied on
663
+ // any of those reaches `stepKeyString` unchecked: the optional path, which is the one nobody
664
+ // writes tests for, feeds the same durable journal key as the required one. A name containing the
665
+ // characters the key grammar reserves forges structure: two different programs then print one
666
+ // identical key, and with matching inputs the collision is entirely silent.
667
+ if (name !== "checkpoint") {
578
668
  const prop = given.get("name");
579
669
  if (prop === undefined) {
580
- v.fail("L3012", node, `Every \`${name}\` needs a name, because its journal entry is keyed by that name rather than by its position. Without one, a resumed run cannot tell this step from any other.`, `Add a kebab-case name literal: ${name}(..., { name: "..." })`, name);
670
+ if (spec.nameRequired) {
671
+ v.fail("L3012", node, `Every \`${name}\` needs a name, because its journal entry is keyed by that name rather than by its position. Without one, a resumed run cannot tell this step from any other.`, `Add a kebab-case name literal: ${name}(..., { name: "..." })`, name);
672
+ }
581
673
  }
582
674
  else {
583
675
  const value = prop.value;
584
- if (value.type !== "Literal" || typeof value.value !== "string") {
585
- v.fail("L3013", value, "A step name must be a string literal, because the flowchart, the linter, and the migration report all read it without running the program.", 'Use a literal: { name: "build" }', name);
676
+ const isLiteral = value.type === "Literal" && typeof value.value === "string";
677
+ // L3013 stays gated on `nameRequired`. Widening it to every present name refused
678
+ // `fanOut([...], async (lens) => sleep("1m", { name: lens }))` — naming a branch's step after
679
+ // its item, which is idiomatic and is how a fan-out gets distinct keys at all. That is the
680
+ // cost this restriction would have had, and it is too high for what it buys: the SHAPE check
681
+ // below is what closes the forgery, and it does not need the name to be static.
682
+ if (!isLiteral) {
683
+ if (spec.nameRequired) {
684
+ v.fail("L3013", value, "A step name must be a string literal, because the flowchart, the linter, and the migration report all read it without running the program.", 'Use a literal: { name: "build" }', name);
685
+ }
686
+ // A COMPUTED name cannot be checked here at all, and it reaches the same journal key. The
687
+ // refusal for that one lives at key construction, where the value exists — see keys.ts.
586
688
  }
587
689
  else if (!STEP_NAME_RE.test(value.value)) {
588
690
  v.fail("L3014", value, `"${value.value}" is not a well-formed step name.`, 'Use kebab-case, 1 to 64 characters: { name: "build" }', name);
@@ -610,11 +712,287 @@ function checkCall(node, v) {
610
712
  v.warn("L3023", branches, "Array branches are keyed by index, so inserting a branch shifts every later branch's journal namespace and re-runs its steps.", `Use the record form: ${name}({ lint: () => ..., tests: () => ... }, { name: "checks" })`, name);
611
713
  }
612
714
  }
715
+ // A record-form branch KEY becomes a path segment verbatim: `frameString` builds
716
+ // `/kind:name#occ/b:${branch}` by concatenation and escapes nothing. So a key containing one of
717
+ // the three characters the key grammar reserves forges structure — a single branch named
718
+ // `a/parallel:inner#0/b:b` prints the identical key to a genuinely nested `a` → `inner` → `b`,
719
+ // and the journal cannot tell the two locations apart. With different inputs that surfaces as a
720
+ // spurious L5001 divergence for a program that never diverged; with matching inputs it is
721
+ // SILENT, one durable row serving both, and a replay hands the second location the first's
722
+ // recorded effect.
723
+ //
724
+ // Only the reserved characters are rejected, NOT the full step-name pattern: branch keys are
725
+ // ordinary object keys and `{ runTests: ... }` is legitimate, so requiring kebab-case here would
726
+ // refuse correct programs to fix a forgery that needs `/`, `#` or `:`. The restriction is the
727
+ // narrowest one that closes it.
728
+ if (name === "parallel" || name === "race" || name === "fanOut") {
729
+ const branches = args[0];
730
+ if (branches !== undefined && branches.type === "ObjectExpression") {
731
+ for (const prop of branches.properties ?? []) {
732
+ const key = prop.key;
733
+ if (key === undefined)
734
+ continue;
735
+ const text = key.type === "Identifier" ? key.name
736
+ : key.type === "Literal" && typeof key.value === "string" ? key.value
737
+ : undefined;
738
+ if (text === undefined || !KEY_RESERVED_RE.test(text))
739
+ continue;
740
+ v.fail("L3025", key, `The branch key "${text}" contains a character the step-key grammar reserves (\`/\`, \`#\` or \`:\`). Branch keys are written into the journal key verbatim, so this one can spell a path that a genuinely nested scope also produces — and two different locations that share a key share a durable row, silently when their inputs match.`, "Use a branch key without `/`, `#` or `:`.", name);
741
+ }
742
+ }
743
+ }
744
+ // L2032: a branch that WRITES a binding declared outside it.
745
+ //
746
+ // Freeze-on-share does not see this one, because nothing crosses an effect boundary: the branches
747
+ // set a scalar in the enclosing scope. And the damage is silent. Live, the branches write in
748
+ // COMPLETION order; on resume the journalled effects return instantly, so they write in LAUNCH
749
+ // order, the binding ends up holding a different value, and the resumed run takes a path it never
750
+ // recorded — with no divergence raised, because no effect's inputs changed.
751
+ //
752
+ // `conclave` is deliberately not here. Its body is a single thunk with nothing to race, so a
753
+ // write from inside it is as ordered as a write anywhere else in the program.
754
+ if (name === "parallel" || name === "race" || name === "fanOut") {
755
+ // One `seen` set per combinator call: two branches calling the same helper is one defect in
756
+ // that helper, not two, and reporting it twice tells an author to fix one line twice.
757
+ const seen = new Set();
758
+ const thunks = name === "fanOut" ? branchThunks(args[1], v, scope) : branchThunks(args[0], v, scope);
759
+ for (const thunk of thunks)
760
+ checkCapturedWrites(thunk, name, v, seen);
761
+ }
762
+ }
763
+ function isFunctionNode(node) {
764
+ return (node.type === "ArrowFunctionExpression" ||
765
+ node.type === "FunctionExpression" ||
766
+ node.type === "FunctionDeclaration");
767
+ }
768
+ /**
769
+ * Index every function the program names, so L2032 can follow a branch that is not written at the
770
+ * combinator call. Ambiguous names (two functions, one name) index as `null` and resolve to
771
+ * nothing — an unproven branch is left to the interpreter's runtime check rather than guessed at.
772
+ */
773
+ function indexFunctions(node, v) {
774
+ const put = (name, fn) => {
775
+ const prior = v.functions.get(name);
776
+ v.functions.set(name, prior === undefined || prior === fn ? fn : null);
777
+ };
778
+ if (node.type === "FunctionDeclaration" && isNode(node.id)) {
779
+ put(node.id.name, node);
780
+ }
781
+ if (node.type === "VariableDeclarator" && isNode(node.id) && node.id.type === "Identifier") {
782
+ const init = node.init;
783
+ if (isNode(init) && isFunctionNode(init))
784
+ put(node.id.name, init);
785
+ }
786
+ for (const c of children(node))
787
+ indexFunctions(c, v);
788
+ }
789
+ /**
790
+ * Resolve a branch to the function it names, or to nothing.
791
+ *
792
+ * A NAMED branch resolves through its lexical binding at the combinator call, never through the
793
+ * program-wide name map: `async function use(branch) { await parallel({ branch }) }` passes its own
794
+ * parameter, and resolving that name to a same-named top-level declaration made a clean program
795
+ * unwriteable — L2032 blamed a write in a function the program never even called. A name bound to
796
+ * something that is not a function is UNPROVEN, and unproven is the interpreter's depth check to
797
+ * refuse at runtime, not the validator's to guess at.
798
+ */
799
+ function resolveFunction(node, v, scope) {
800
+ if (isFunctionNode(node))
801
+ return node;
802
+ if (node.type !== "Identifier")
803
+ return undefined;
804
+ const name = node.name;
805
+ // Bound here: the binding answers, whatever it is bound to. Unbound: walk 2 has already raised
806
+ // L2001 for it, so the module index is the fallback.
807
+ if (scope.lookup(name) !== undefined)
808
+ return scope.lookupFn(name);
809
+ return v.functions.get(name) ?? undefined;
810
+ }
811
+ /**
812
+ * The thunks a combinator owns, in every form the language accepts.
813
+ *
814
+ * `parallel({ a: () => …, b })` mixes an inline branch and a NAMED one, and a first version of this
815
+ * dropped the named half — so a captured-write program written with two named
816
+ * `async function`s instead of two arrows, was accepted. A branch is a branch however it is
817
+ * spelled.
818
+ */
819
+ function branchThunks(node, v, scope) {
820
+ if (node === undefined)
821
+ return [];
822
+ const one = resolveFunction(node, v, scope);
823
+ if (one !== undefined)
824
+ return [one];
825
+ const out = [];
826
+ if (node.type === "ObjectExpression") {
827
+ for (const p of node.properties ?? []) {
828
+ if (p.type !== "Property" || !isNode(p.value))
829
+ continue;
830
+ const fn = resolveFunction(p.value, v, scope);
831
+ if (fn !== undefined)
832
+ out.push(fn);
833
+ }
834
+ }
835
+ if (node.type === "ArrayExpression") {
836
+ for (const el of node.elements ?? []) {
837
+ if (el === null || !isNode(el))
838
+ continue;
839
+ const fn = resolveFunction(el, v, scope);
840
+ if (fn !== undefined)
841
+ out.push(fn);
842
+ }
843
+ }
844
+ return out;
845
+ }
846
+ /** The identifier a write lands on: `x` for `x`, `x.a`, `x[i].b` and `x?.a`. */
847
+ function rootIdentifier(node) {
848
+ if (node === undefined || !isNode(node))
849
+ return undefined;
850
+ if (node.type === "Identifier")
851
+ return node;
852
+ if (node.type === "MemberExpression")
853
+ return rootIdentifier(node.object);
854
+ if (node.type === "ChainExpression")
855
+ return rootIdentifier(node.expression);
856
+ return undefined;
857
+ }
858
+ function capturedWrite(at, name, combinator, v) {
859
+ v.fail("L2032", at, `\`${name}\` is declared outside this branch and written inside it. Two branches racing to write one place is nondeterministic, and freezing does not cover it because nothing crosses an effect boundary. It is also silent: live, the branches write in completion order, but on resume the recorded effects return instantly and they write in launch order, so \`${name}\` holds a different value and the run takes a path it never recorded — with no divergence raised, because no effect's inputs changed.`, `Return the value from the branch and read it out of \`${combinator}\`'s result, or use \`race\`, which yields its winner.`, combinator);
860
+ }
861
+ /** Walk one branch thunk with its OWN scope chain: anything it did not declare, it captured. */
862
+ function checkCapturedWrites(fn, combinator, v, seen) {
863
+ if (seen.has(fn))
864
+ return;
865
+ seen.add(fn);
866
+ const local = new Scope(null);
867
+ for (const p of fn.params ?? []) {
868
+ const names = [];
869
+ patternNames(p, names);
870
+ for (const n of names)
871
+ local.declare(n, "param");
872
+ }
873
+ if (isNode(fn.body))
874
+ walkCaptured(fn.body, local, combinator, v, seen);
875
+ }
876
+ function walkCaptured(node, scope, combinator, v, seen) {
877
+ switch (node.type) {
878
+ case "VariableDeclaration": {
879
+ const kind = node.kind === "const" ? "const" : "let";
880
+ for (const d of node.declarations ?? []) {
881
+ if (isNode(d.init))
882
+ walkCaptured(d.init, scope, combinator, v, seen);
883
+ const names = [];
884
+ patternNames(d.id, names);
885
+ for (const n of names)
886
+ scope.declare(n, kind);
887
+ }
888
+ return;
889
+ }
890
+ case "AssignmentExpression":
891
+ case "UpdateExpression": {
892
+ // `x = 1`, `x += 1`, `x++`, `x.a = 1` and `x[i] += 1` are the same defect: something declared
893
+ // outside the branch is written inside it. A write through a member expression reaches the
894
+ // VALUE the outer binding holds, which is no more ordered across branches than the binding
895
+ // itself; the runtime half (values carry the depth they were born at) covers what a value
896
+ // reached through an alias hides from this walk.
897
+ const target = (node.type === "AssignmentExpression" ? node.left : node.argument);
898
+ const root = rootIdentifier(target);
899
+ if (root !== undefined && scope.lookup(root.name) === undefined) {
900
+ capturedWrite(root, root.name, combinator, v);
901
+ }
902
+ for (const c of children(node))
903
+ walkCaptured(c, scope, combinator, v, seen);
904
+ return;
905
+ }
906
+ case "CallExpression": {
907
+ // FOLLOW THE CALL. A branch that writes nothing itself and calls a helper that writes the
908
+ // outer binding is the same defect one level down, and it is invisible at the combinator call.
909
+ // The helper is checked as a branch in its own right, with its own scope, so the blame lands
910
+ // on the line that actually writes — and a helper that only touches its own locals is clean,
911
+ // which is what keeps ordinary shared procedures usable.
912
+ const callee = node.callee;
913
+ if (isNode(callee) && callee.type === "Identifier") {
914
+ const nm = callee.name;
915
+ // A locally-declared name is a local binding, not the program-level function of that name.
916
+ if (scope.lookup(nm) === undefined) {
917
+ const target = v.functions.get(nm);
918
+ if (target !== undefined && target !== null)
919
+ checkCapturedWrites(target, combinator, v, seen);
920
+ }
921
+ }
922
+ // `outer.push(x)` from a branch is `outer[len(outer)] = x` spelled as a method.
923
+ if (isNode(callee) && callee.type === "MemberExpression") {
924
+ const member = callee;
925
+ const method = memberName(member);
926
+ const root = rootIdentifier(member.object);
927
+ if (method !== null &&
928
+ MUTATING_METHODS.has(method) &&
929
+ root !== undefined &&
930
+ scope.lookup(root.name) === undefined) {
931
+ capturedWrite(root, root.name, combinator, v);
932
+ }
933
+ }
934
+ for (const c of children(node))
935
+ walkCaptured(c, scope, combinator, v, seen);
936
+ return;
937
+ }
938
+ case "FunctionDeclaration":
939
+ case "FunctionExpression":
940
+ case "ArrowFunctionExpression": {
941
+ if (node.type === "FunctionDeclaration" && isNode(node.id)) {
942
+ scope.declare(node.id.name, "const");
943
+ }
944
+ const inner = new Scope(scope);
945
+ for (const p of node.params ?? []) {
946
+ const names = [];
947
+ patternNames(p, names);
948
+ for (const n of names)
949
+ inner.declare(n, "param");
950
+ }
951
+ if (isNode(node.body))
952
+ walkCaptured(node.body, inner, combinator, v, seen);
953
+ return;
954
+ }
955
+ case "BlockStatement": {
956
+ const inner = new Scope(scope);
957
+ hoistFunctions(node, inner);
958
+ for (const s of node.body ?? [])
959
+ walkCaptured(s, inner, combinator, v, seen);
960
+ return;
961
+ }
962
+ case "CatchClause": {
963
+ const inner = new Scope(scope);
964
+ if (isNode(node.param)) {
965
+ const names = [];
966
+ patternNames(node.param, names);
967
+ for (const n of names)
968
+ inner.declare(n, "const");
969
+ }
970
+ if (isNode(node.body))
971
+ walkCaptured(node.body, inner, combinator, v, seen);
972
+ return;
973
+ }
974
+ case "ForStatement":
975
+ case "ForOfStatement": {
976
+ const inner = new Scope(scope);
977
+ for (const c of children(node))
978
+ walkCaptured(c, inner, combinator, v, seen);
979
+ return;
980
+ }
981
+ default: {
982
+ for (const c of children(node))
983
+ walkCaptured(c, scope, combinator, v, seen);
984
+ return;
985
+ }
986
+ }
613
987
  }
614
988
  function walkResolve(node, v, scope) {
615
989
  switch (node.type) {
616
990
  case "Identifier": {
617
991
  const name = node.name;
992
+ if (scope.refersToPending(name)) {
993
+ v.fail("L2004", node, `\`${name}\` is declared later in this block, and a \`let\` or \`const\` binds the whole block: executed, this line would find a binding that holds no value yet (JavaScript's temporal dead zone, a guaranteed runtime error in straight-line code).`, "Move the declaration above its first use, or rename one of the two.");
994
+ return;
995
+ }
618
996
  if (scope.lookup(name) !== undefined)
619
997
  return;
620
998
  if (RESERVED_NAMES.has(name))
@@ -624,7 +1002,8 @@ function walkResolve(node, v, scope) {
624
1002
  return;
625
1003
  }
626
1004
  if (FORBIDDEN_GLOBALS.has(name)) {
627
- v.fail("L2012", node, `\`${name}\` is a host global. There is no ambient IO, clock, or randomness here: the interpreter has nothing nondeterministic to offer.`, "Use `now()` for time, `random()` for randomness, `sleep()` to wait, and `log()` for output.");
1005
+ v.fail("L2012", node, `\`${name}\` is a host global. There is no ambient IO, clock, or randomness here: the interpreter has nothing nondeterministic to offer.`, HOST_GLOBAL_HINTS[name] ??
1006
+ "Use `now()` for time, `random()` for randomness, `sleep()` to wait, and `log()` for output.");
628
1007
  return;
629
1008
  }
630
1009
  v.fail("L2001", node, `\`${name}\` is not defined anywhere in this program. Every name resolves when the program is read, so this is never a runtime surprise.`, `Define it, or check the spelling. The builtins are: ${BUILTINS.join(", ")}.`);
@@ -638,11 +1017,18 @@ function walkResolve(node, v, scope) {
638
1017
  walkResolve(init, v, scope);
639
1018
  const names = [];
640
1019
  patternNames(d.id, names);
1020
+ // `const b = async () => {...}` binds a FUNCTION, and a branch named `b` must resolve to
1021
+ // it. Only the plain `id = function` form does: a destructured name holds whatever the
1022
+ // pattern pulled out, which is not statically a function.
1023
+ const bound = isNode(d.id) && d.id.type === "Identifier" && isNode(init) && isFunctionNode(init)
1024
+ ? init
1025
+ : undefined;
641
1026
  for (const n of names) {
642
1027
  if (RESERVED_NAMES.has(n)) {
643
1028
  v.fail("L2002", d.id, `\`${n}\` is a builtin, so shadowing it would make a call to \`${n}\` mean two different things in one program.`, `Rename the binding, for example \`${n}Result\`.`);
644
1029
  }
645
- scope.declare(n, kind);
1030
+ scope.declare(n, kind, bound);
1031
+ scope.pending.delete(n);
646
1032
  }
647
1033
  }
648
1034
  return;
@@ -681,21 +1067,46 @@ function walkResolve(node, v, scope) {
681
1067
  if (RESERVED_NAMES.has(fname)) {
682
1068
  v.fail("L2002", node.id, `\`${fname}\` is a builtin, so a function of that name would shadow it.`, "Rename the function.");
683
1069
  }
684
- scope.declare(fname, "const");
1070
+ scope.declare(fname, "const", node);
685
1071
  }
686
1072
  const inner = new Scope(scope);
687
- for (const p of node.params ?? []) {
1073
+ inner.fnBoundary = true;
1074
+ // A NAMED function expression binds its own name inside itself, which is how a function
1075
+ // assigned to a `const` recurses: `const f = function walk(n) { ... walk(n - 1) ... }`.
1076
+ if (node.type === "FunctionExpression" && isNode(node.id)) {
1077
+ const fname = node.id.name;
1078
+ if (RESERVED_NAMES.has(fname)) {
1079
+ v.fail("L2002", node.id, `\`${fname}\` is a builtin, so a function expression of that name would shadow it inside itself.`, "Rename the function.");
1080
+ }
1081
+ inner.declare(fname, "const", node);
1082
+ }
1083
+ // Parameters bind LEFT TO RIGHT, and a default sees only the parameters before it: every
1084
+ // name is pre-declared pending, and each parameter leaves the dead zone only after its own
1085
+ // default was walked, so `(a = b, b = 1)` and `(a = a)` are L2004 the way straight-line
1086
+ // block references are — JavaScript would throw the same at every call that evaluates the
1087
+ // default, and a default that cannot ever evaluate is a landmine, not a feature.
1088
+ const params = node.params ?? [];
1089
+ const paramNames = params.map((p) => {
688
1090
  const names = [];
689
1091
  patternNames(p, names);
690
- for (const n of names) {
1092
+ return names;
1093
+ });
1094
+ for (let i = 0; i < params.length; i += 1) {
1095
+ const p = params[i];
1096
+ for (const n of paramNames[i]) {
691
1097
  if (RESERVED_NAMES.has(n)) {
692
1098
  v.fail("L2002", p, `\`${n}\` is a builtin, so a parameter of that name would shadow it inside this function.`, "Rename the parameter.");
693
1099
  }
694
1100
  inner.declare(n, "param");
1101
+ inner.pending.add(n);
695
1102
  }
696
- // Default values are evaluated in the inner scope.
1103
+ }
1104
+ for (let i = 0; i < params.length; i += 1) {
1105
+ const p = params[i];
697
1106
  if (p.type === "AssignmentPattern" && isNode(p.right))
698
1107
  walkResolve(p.right, v, inner);
1108
+ for (const n of paramNames[i])
1109
+ inner.pending.delete(n);
699
1110
  }
700
1111
  if (isNode(node.body))
701
1112
  walkResolve(node.body, v, inner);
@@ -704,6 +1115,7 @@ function walkResolve(node, v, scope) {
704
1115
  case "BlockStatement": {
705
1116
  const inner = new Scope(scope);
706
1117
  hoistFunctions(node, inner);
1118
+ hoistBindings(node, inner);
707
1119
  for (const s of node.body ?? [])
708
1120
  walkResolve(s, v, inner);
709
1121
  return;
@@ -728,7 +1140,7 @@ function walkResolve(node, v, scope) {
728
1140
  return;
729
1141
  }
730
1142
  case "CallExpression": {
731
- checkCall(node, v);
1143
+ checkCall(node, v, scope);
732
1144
  for (const c of children(node))
733
1145
  walkResolve(c, v, scope);
734
1146
  return;
@@ -744,7 +1156,26 @@ function walkResolve(node, v, scope) {
744
1156
  function hoistFunctions(block, scope) {
745
1157
  for (const s of block.body ?? []) {
746
1158
  if (s.type === "FunctionDeclaration" && isNode(s.id)) {
747
- scope.declare(s.id.name, "const");
1159
+ scope.declare(s.id.name, "const", s);
1160
+ }
1161
+ }
1162
+ }
1163
+ /**
1164
+ * `let`/`const` bind the whole block too — as PENDING, so a straight-line reference above the
1165
+ * declaration is L2004 rather than a resolution to whatever outer binding shares the name.
1166
+ */
1167
+ function hoistBindings(block, scope) {
1168
+ for (const s of block.body ?? []) {
1169
+ if (s.type !== "VariableDeclaration")
1170
+ continue;
1171
+ const kind = s.kind === "const" ? "const" : "let";
1172
+ for (const d of s.declarations ?? []) {
1173
+ const names = [];
1174
+ patternNames(d.id, names);
1175
+ for (const n of names) {
1176
+ scope.declare(n, kind);
1177
+ scope.pending.add(n);
1178
+ }
748
1179
  }
749
1180
  }
750
1181
  }
@@ -782,8 +1213,10 @@ export function validate(source, file = "program.cotal.js") {
782
1213
  }
783
1214
  // The module body is an async context: a program's top level is where the workflow lives.
784
1215
  walkShape(ast, v, true);
1216
+ indexFunctions(ast, v);
785
1217
  const top = new Scope(null);
786
1218
  hoistFunctions(ast, top);
1219
+ hoistBindings(ast, top);
787
1220
  for (const s of ast.body ?? [])
788
1221
  walkResolve(s, v, top);
789
1222
  if (v.errors.length > 0)