@velarscript/compiler 0.12.0 → 0.13.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 (109) hide show
  1. package/dist/advisory-suppression.d.ts +62 -0
  2. package/dist/advisory-suppression.d.ts.map +1 -0
  3. package/dist/advisory-suppression.js +179 -0
  4. package/dist/advisory-suppression.js.map +1 -0
  5. package/dist/analyzer.d.ts +405 -17
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/analyzer.js +1755 -211
  8. package/dist/analyzer.js.map +1 -1
  9. package/dist/ast.d.ts +6 -3
  10. package/dist/ast.d.ts.map +1 -1
  11. package/dist/ast.js.map +1 -1
  12. package/dist/binding-stability.d.ts +22 -0
  13. package/dist/binding-stability.d.ts.map +1 -0
  14. package/dist/binding-stability.js +63 -0
  15. package/dist/binding-stability.js.map +1 -0
  16. package/dist/collection-lowering-runtime.d.ts +1 -1
  17. package/dist/collection-lowering-runtime.d.ts.map +1 -1
  18. package/dist/collection-lowering-runtime.js +183 -56
  19. package/dist/collection-lowering-runtime.js.map +1 -1
  20. package/dist/diagnostic.d.ts +34 -0
  21. package/dist/diagnostic.d.ts.map +1 -1
  22. package/dist/diagnostic.js +11 -1
  23. package/dist/diagnostic.js.map +1 -1
  24. package/dist/embedded-javascript.d.ts +12 -1
  25. package/dist/embedded-javascript.d.ts.map +1 -1
  26. package/dist/embedded-javascript.js +111 -2
  27. package/dist/embedded-javascript.js.map +1 -1
  28. package/dist/embedded-module.d.ts.map +1 -1
  29. package/dist/embedded-module.js +22 -0
  30. package/dist/embedded-module.js.map +1 -1
  31. package/dist/emitter.d.ts +83 -1
  32. package/dist/emitter.d.ts.map +1 -1
  33. package/dist/emitter.js +518 -41
  34. package/dist/emitter.js.map +1 -1
  35. package/dist/error-runtime.d.ts +9 -0
  36. package/dist/error-runtime.d.ts.map +1 -1
  37. package/dist/error-runtime.js +21 -0
  38. package/dist/error-runtime.js.map +1 -1
  39. package/dist/extension.d.ts +5 -2
  40. package/dist/extension.d.ts.map +1 -1
  41. package/dist/extension.js +2 -1
  42. package/dist/extension.js.map +1 -1
  43. package/dist/formatter.d.ts.map +1 -1
  44. package/dist/formatter.js +222 -91
  45. package/dist/formatter.js.map +1 -1
  46. package/dist/index.d.ts +10 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +43 -5
  49. package/dist/index.js.map +1 -1
  50. package/dist/interpolated-string.d.ts.map +1 -1
  51. package/dist/interpolated-string.js +29 -2
  52. package/dist/interpolated-string.js.map +1 -1
  53. package/dist/json-runtime.d.ts.map +1 -1
  54. package/dist/json-runtime.js +27 -3
  55. package/dist/json-runtime.js.map +1 -1
  56. package/dist/lexer.d.ts +217 -1
  57. package/dist/lexer.d.ts.map +1 -1
  58. package/dist/lexer.js +799 -37
  59. package/dist/lexer.js.map +1 -1
  60. package/dist/limits.d.ts +9 -0
  61. package/dist/limits.d.ts.map +1 -1
  62. package/dist/limits.js +9 -0
  63. package/dist/limits.js.map +1 -1
  64. package/dist/mechanical-fix.d.ts.map +1 -1
  65. package/dist/mechanical-fix.js +38 -0
  66. package/dist/mechanical-fix.js.map +1 -1
  67. package/dist/parser.d.ts +30 -1
  68. package/dist/parser.d.ts.map +1 -1
  69. package/dist/parser.js +137 -21
  70. package/dist/parser.js.map +1 -1
  71. package/dist/promise-runtime.d.ts +20 -1
  72. package/dist/promise-runtime.d.ts.map +1 -1
  73. package/dist/promise-runtime.js +38 -3
  74. package/dist/promise-runtime.js.map +1 -1
  75. package/dist/reactive-bridge-runtime.d.ts.map +1 -1
  76. package/dist/reactive-bridge-runtime.js +7 -1
  77. package/dist/reactive-bridge-runtime.js.map +1 -1
  78. package/dist/runtime-abi.d.ts +21 -1
  79. package/dist/runtime-abi.d.ts.map +1 -1
  80. package/dist/runtime-abi.js +23 -3
  81. package/dist/runtime-abi.js.map +1 -1
  82. package/dist/source-names.d.ts +26 -0
  83. package/dist/source-names.d.ts.map +1 -1
  84. package/dist/source-names.js +49 -5
  85. package/dist/source-names.js.map +1 -1
  86. package/dist/source.d.ts.map +1 -1
  87. package/dist/source.js +13 -0
  88. package/dist/source.js.map +1 -1
  89. package/dist/stable-order.d.ts +16 -0
  90. package/dist/stable-order.d.ts.map +1 -0
  91. package/dist/stable-order.js +18 -0
  92. package/dist/stable-order.js.map +1 -0
  93. package/dist/text-runtime.d.ts.map +1 -1
  94. package/dist/text-runtime.js +182 -41
  95. package/dist/text-runtime.js.map +1 -1
  96. package/dist/token.d.ts +11 -0
  97. package/dist/token.d.ts.map +1 -1
  98. package/dist/token.js.map +1 -1
  99. package/dist/type-registry-runtime.d.ts.map +1 -1
  100. package/dist/type-registry-runtime.js +8 -1
  101. package/dist/type-registry-runtime.js.map +1 -1
  102. package/dist/type-validation-runtime.d.ts.map +1 -1
  103. package/dist/type-validation-runtime.js +129 -1
  104. package/dist/type-validation-runtime.js.map +1 -1
  105. package/dist/types.d.ts +17 -1
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +161 -17
  108. package/dist/types.js.map +1 -1
  109. package/package.json +1 -1
package/dist/analyzer.js CHANGED
@@ -1,11 +1,90 @@
1
1
  import { blockContainsDirectAwait } from "./ast.js";
2
2
  import { isPermanentNamespaceName } from "./core-vocabulary.js";
3
- import { diagnostic, mechanicalEdits, mechanicalFix, recoveredDiagnostic } from "./diagnostic.js";
3
+ import { advisory, diagnostic, mechanicalEdits, mechanicalFix, recoveredDiagnostic } from "./diagnostic.js";
4
4
  import { VELAR_HOST_ERROR_NAMES, VELAR_HOST_ERROR_PATH_NAMES } from "./error-runtime.js";
5
5
  import { collectionMemberGuidance, removedGlobalFunctionGuidance, stringMemberGuidance, REST_PARAMETER_ELEMENT_TYPE_MESSAGE } from "./language-guidance.js";
6
6
  import { bindingNameRestriction } from "./source-names.js";
7
7
  import { span, spanIdentity } from "./source.js";
8
- import { analysisTypeIdentity, anyType, binaryStorageKind, boolType, boundGrants, collectGenericBoundViolations, collectTypeArgumentBoundViolations, describeType, genericApplicationType, instantiateGenericCallable, invalidType, isInvalidType, isAssignable, isReadonlyView, isTextConvertibleType, isTypeParameterBound, typeParameterBoundNames, mergeTypes, mutableViewOf, nullType, nonOptional, numberType, optionalOf, resolveTypeReference, readonlyViewOf, resolvedAsyncType, semanticTypeIdentity, sameType, sameTypeIgnoringCallableParameterNames, stringType, substituteTypeParameters, textConvertibleType, typeContainsParameter, typeContainsRuntimeTypeCheck, unifyTypeParameters, unionOf, unknownType, } from "./types.js";
8
+ import { analysisTypeIdentity, anyType, binaryStorageKind, boolType, boundGrants, boundaryUnknownType, collectGenericBoundViolations, collectTypeArgumentBoundViolations, describeType, genericApplicationType, instantiateGenericCallable, invalidType, isInvalidType, isAssignable, isReadonlyView, isTextConvertibleType, isTypeParameterBound, typeParameterBoundNames, mergeTypes, mutableViewOf, nullType, nonOptional, numberType, optionalOf, resolveTypeReference, readonlyViewOf, resolvedAsyncType, semanticTypeIdentity, sameType, sameTypeIgnoringCallableParameterNames, stringType, substituteTypeParameters, textConvertibleType, typeContainsAnyOutput, typeContainsParameter, typeContainsRuntimeTypeCheck, unifyTypeParameters, unionOf, unknownType, } from "./types.js";
9
+ /**
10
+ * How many back-edge passes may be running at once. One is what a single-level
11
+ * loop needs, and three covers a loop nest three deep — the deepest anyone
12
+ * writes on purpose — with every level analyzed exactly as before. Past that
13
+ * the count stops doubling per level: fourteen levels went from 3.2 seconds to
14
+ * 0.2, and seventeen from 30 seconds to 0.4.
15
+ */
16
+ const maximumLoopReanalysisDepth = 3;
17
+ /**
18
+ * D90 R12: the class and record names a consumer can read a value *out of*
19
+ * this type, collected into two frontiers so `exportReachableClasses` can walk
20
+ * a record's fields without recursing through a cyclic record here.
21
+ *
22
+ * It visits output positions for the reason `typeContainsAnyOutput` (types.ts)
23
+ * does: an input position accepts a value *from* the consumer, so a class
24
+ * named there is one the consumer already had. The one deliberate difference
25
+ * is an extension type's `properties`, documented at that case.
26
+ */
27
+ function collectOutputTypeNames(type, classes, records) {
28
+ switch (type.kind) {
29
+ case "class":
30
+ case "classConstructor":
31
+ classes.push(type.name);
32
+ return;
33
+ case "optional":
34
+ collectOutputTypeNames(type.inner, classes, records);
35
+ return;
36
+ case "list":
37
+ case "set":
38
+ collectOutputTypeNames(type.element, classes, records);
39
+ return;
40
+ case "map":
41
+ collectOutputTypeNames(type.key, classes, records);
42
+ collectOutputTypeNames(type.value, classes, records);
43
+ return;
44
+ case "record":
45
+ case "promise":
46
+ case "runtimeType":
47
+ collectOutputTypeNames(type.value, classes, records);
48
+ return;
49
+ case "object":
50
+ for (const field of type.fields.values())
51
+ collectOutputTypeNames(field, classes, records);
52
+ return;
53
+ case "named":
54
+ case "typeObject":
55
+ // A `named` may still denote a class before resolveNamedClasses runs, so
56
+ // the name joins both frontiers; the one that does not match a
57
+ // declaration simply finds nothing.
58
+ classes.push(type.name);
59
+ records.push(type.name);
60
+ for (const argument of type.kind === "named" ? type.application?.arguments ?? [] : []) {
61
+ collectOutputTypeNames(argument, classes, records);
62
+ }
63
+ return;
64
+ case "extension":
65
+ // An extension family's `properties` are its *named parameters* — a Web
66
+ // component's props are supplied by whoever renders it — so they are the
67
+ // `function` case's `parameters` under another spelling, and are skipped
68
+ // for the same reason. Walking them made `export component Panel(inner:
69
+ // Inner)` report `Inner`'s inferred member while `export def take(box:
70
+ // Inner)` stayed silent: one question, two answers. `arguments` carry
71
+ // the family's payload — a component's exposed Handle, a route input's
72
+ // validated value, a provider's result — which a consumer does read out.
73
+ for (const argument of type.arguments)
74
+ collectOutputTypeNames(argument, classes, records);
75
+ return;
76
+ case "function":
77
+ case "action":
78
+ case "intrinsic":
79
+ collectOutputTypeNames(type.result, classes, records);
80
+ return;
81
+ case "union":
82
+ for (const member of type.members)
83
+ collectOutputTypeNames(member, classes, records);
84
+ return;
85
+ default:
86
+ }
87
+ }
9
88
  function continuesOptionalChain(expression) {
10
89
  if (expression.kind === "MemberExpression") {
11
90
  return expression.optional || continuesOptionalChain(expression.object);
@@ -99,26 +178,53 @@ function sameInferredResult(left, right) {
99
178
  return false;
100
179
  return sameType(left, right);
101
180
  }
181
+ /** The furthest a suggestion may be from what was written. */
182
+ const nearestNameLimit = 2;
183
+ /**
184
+ * Edit distance, abandoned as soon as it is known to exceed `nearestNameLimit`.
185
+ * Only cells within that many steps of the diagonal can hold a value inside the
186
+ * limit, so each row is a fixed-width band rather than the whole right operand,
187
+ * and a row whose every cell is already over the limit ends the walk.
188
+ */
189
+ function boundedEditDistance(left, right) {
190
+ const over = nearestNameLimit + 1;
191
+ if (Math.abs(left.length - right.length) > nearestNameLimit)
192
+ return over;
193
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
194
+ let current = new Array(right.length + 1).fill(over);
195
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
196
+ const from = Math.max(1, leftIndex - nearestNameLimit);
197
+ const to = Math.min(right.length, leftIndex + nearestNameLimit);
198
+ current[from - 1] = from === 1 ? leftIndex : over;
199
+ let rowBest = current[from - 1];
200
+ for (let rightIndex = from; rightIndex <= to; rightIndex += 1) {
201
+ const cell = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1));
202
+ current[rightIndex] = cell;
203
+ if (cell < rowBest)
204
+ rowBest = cell;
205
+ }
206
+ if (to < right.length)
207
+ current[to + 1] = over;
208
+ if (rowBest > nearestNameLimit)
209
+ return over;
210
+ const swap = previous;
211
+ previous = current;
212
+ current = swap;
213
+ }
214
+ return previous[right.length];
215
+ }
102
216
  /** Returns the sole nearest spelling within two edits, never an ambiguous guess. */
103
217
  function uniqueNearestName(requested, candidates) {
104
- const distance = (left, right) => {
105
- let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
106
- for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
107
- const current = [leftIndex];
108
- for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
109
- current[rightIndex] = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1));
110
- }
111
- previous = current;
112
- }
113
- return previous[right.length];
114
- };
218
+ // A Set argument is already deduplicated; copying it again cost one full
219
+ // rebuild per unresolved name for nothing.
220
+ const unique = candidates instanceof Set ? candidates : new Set(candidates);
115
221
  let best = null;
116
- let bestDistance = 3;
222
+ let bestDistance = nearestNameLimit + 1;
117
223
  let tied = false;
118
- for (const candidate of new Set(candidates)) {
119
- if (candidate === requested || Math.abs(candidate.length - requested.length) > 2)
224
+ for (const candidate of unique) {
225
+ if (candidate === requested || Math.abs(candidate.length - requested.length) > nearestNameLimit)
120
226
  continue;
121
- const candidateDistance = distance(requested, candidate);
227
+ const candidateDistance = boundedEditDistance(requested, candidate);
122
228
  if (candidateDistance < bestDistance) {
123
229
  best = candidate;
124
230
  bestDistance = candidateDistance;
@@ -128,7 +234,83 @@ function uniqueNearestName(requested, candidates) {
128
234
  tied = true;
129
235
  }
130
236
  }
131
- return bestDistance <= 2 && !tied ? best : null;
237
+ return bestDistance <= nearestNameLimit && !tied ? best : null;
238
+ }
239
+ /**
240
+ * The roster a "did you mean" reads. It used to be rebuilt — core vocabulary,
241
+ * extension globals, imports, and every name in every live scope — and then run
242
+ * through a full edit-distance pass, once per unresolved name, which made a
243
+ * module of typos quadratic in its own size. The roster is now maintained as
244
+ * scopes come and go, and each name is filed under the strings left by deleting
245
+ * up to two of its characters: two spellings within two edits always share one
246
+ * of those, so a query reads a few buckets instead of the whole roster.
247
+ */
248
+ class NearestNameRoster {
249
+ counts = new Map();
250
+ /** Filled on the first question asked of the roster; a module with no typo never pays for it. */
251
+ buckets = null;
252
+ add(name) {
253
+ const seen = this.counts.get(name) ?? 0;
254
+ this.counts.set(name, seen + 1);
255
+ if (seen === 0)
256
+ this.file(name);
257
+ }
258
+ remove(name) {
259
+ const seen = this.counts.get(name) ?? 0;
260
+ if (seen === 0)
261
+ return;
262
+ if (seen > 1) {
263
+ this.counts.set(name, seen - 1);
264
+ return;
265
+ }
266
+ this.counts.delete(name);
267
+ if (!this.buckets)
268
+ return;
269
+ for (const key of deletionKeys(name)) {
270
+ const bucket = this.buckets.get(key);
271
+ if (!bucket)
272
+ continue;
273
+ bucket.delete(name);
274
+ if (bucket.size === 0)
275
+ this.buckets.delete(key);
276
+ }
277
+ }
278
+ nearest(requested) {
279
+ if (!this.buckets) {
280
+ this.buckets = new Map();
281
+ for (const name of this.counts.keys())
282
+ this.file(name);
283
+ }
284
+ const candidates = new Set();
285
+ for (const key of deletionKeys(requested)) {
286
+ for (const candidate of this.buckets.get(key) ?? [])
287
+ candidates.add(candidate);
288
+ }
289
+ return uniqueNearestName(requested, candidates);
290
+ }
291
+ file(name) {
292
+ if (!this.buckets)
293
+ return;
294
+ for (const key of deletionKeys(name)) {
295
+ const bucket = this.buckets.get(key);
296
+ if (bucket)
297
+ bucket.add(name);
298
+ else
299
+ this.buckets.set(key, new Set([name]));
300
+ }
301
+ }
302
+ }
303
+ /** `name` with up to `nearestNameLimit` characters deleted, the shared key of any two near spellings. */
304
+ function deletionKeys(name) {
305
+ const keys = [name];
306
+ for (let first = 0; first < name.length; first += 1) {
307
+ const once = name.slice(0, first) + name.slice(first + 1);
308
+ keys.push(once);
309
+ for (let second = first; second < once.length; second += 1) {
310
+ keys.push(once.slice(0, second) + once.slice(second + 1));
311
+ }
312
+ }
313
+ return keys;
132
314
  }
133
315
  const corePrimitiveNames = new Set(["string", "number", "bool", "null", "unknown", "Duration"]);
134
316
  const builtinTypeNames = new Set(["string", "number", "bool", "null", "unknown", "any", "List", "Set", "Map", "Record", "Promise", "Function", "Type", "Duration"]);
@@ -156,6 +338,14 @@ export const disposeMemberKey = "__velar:dispose";
156
338
  * member can answer it by accident and no author call can reach it.
157
339
  */
158
340
  export const iterateMemberKey = "__velar:iterate";
341
+ /**
342
+ * The emitted member behind the asynchronous `@iterate:` form (D90 R18). It is
343
+ * a separate key because the two forms answer different questions — the
344
+ * synchronous member returns the finished collection once, this one is an
345
+ * async method `async for` pulls once per element — so no lowering can confuse
346
+ * one for the other.
347
+ */
348
+ export const iterateAsyncMemberKey = "__velar:iterateAsync";
159
349
  /** What each bound admits, written the way a rejected call needs to hear it. */
160
350
  const boundVocabularyGuidance = {
161
351
  Text: "a Text parameter accepts the types with a hook-free text form — strings, numbers, bools, enums, and null",
@@ -190,23 +380,111 @@ const coreGlobalGuidance = new Map([
190
380
  ["len", "Use 'value.size'; strings and collections measure with the size member"],
191
381
  ["parseInt", "Use 'number(text)', then '.floor()' or '.round()' for an integer; VelarScript has one text-to-number conversion"],
192
382
  ["parseFloat", "Use 'number(text)'; VelarScript has one text-to-number conversion"],
383
+ // D89 (message correction): `enumerate` and `zip` are the two Python loop
384
+ // reflexes that reached an unadorned "Unknown name" with no successor at all
385
+ // — `zip` even earned a "did you mean 'Map'?". `enumerate` has a spelling
386
+ // that needs no import, so the loop is named first; `zip` has none, so its
387
+ // guidance is the import that makes it exist.
388
+ ["enumerate", "Use the two-slot loop — 'for value, index in values:' — which binds the value first; 'enumerate' is an import from \"velar/collections\" when you want the '{index, value}' List itself"],
389
+ ["zip", "Import the builder — 'import {zip} from \"velar/collections\"' — it pairs two Lists as '{first, second}' up to the shorter length"],
193
390
  ["stringify", "Use Json.stringify(value) directly; VelarScript's pure namespaces need no import"],
194
391
  ["parse", "Use Json.parse(text) directly; VelarScript's pure namespaces need no import"],
392
+ // D90 (coherence): the rest of the Python builtin surface a model reaches
393
+ // for. Every one of these had an answer sitting in a roster the compiler
394
+ // already owns, and reached the author either as a bare "Unknown name" or —
395
+ // worse — as a confident edit-distance guess at an unrelated name (`sum` ->
396
+ // `str`, `max` -> `Map`, `map` -> `Map`). Naming the successor also
397
+ // suppresses the guess, because guidance is consulted first.
398
+ ["sum", "Use 'values.sum()'; totalling is a List member"],
399
+ ["min", "Use 'Math.min(a, b)' for two numbers, or 'values.min()' for a List"],
400
+ ["max", "Use 'Math.max(a, b)' for two numbers, or 'values.max()' for a List"],
401
+ ["sorted", "Use 'values.sorted()'; it returns a new List and never mutates the receiver"],
402
+ ["reversed", "Use 'values.reversed()'; it returns a new List and never mutates the receiver"],
403
+ ["any", "Use 'values.some(test)'; the collection members carry the quantifiers"],
404
+ ["all", "Use 'values.every(test)'; the collection members carry the quantifiers"],
405
+ ["filter", "Use 'values.filter(test)'; the collection members carry the transforms"],
406
+ ["map", "Use 'values.map(transform)'; 'Map' with a capital M is the key-value collection, not the transform"],
407
+ ["isinstance", "Use the 'is' operator — 'value is Type' — which also narrows the binding inside the branch"],
408
+ ["pow", "Use 'Math.pow(base, exponent)'"],
409
+ ["divmod", "Use '(a / b).floor()' for the quotient and 'a % b' for the remainder; VelarScript returns one value per operation"],
410
+ ["repr", "Use 'print(value)' to inspect a value, 'str(value)' for its text form, or 'Json.stringify(value)' for data text"],
411
+ ["format", "Use an f-string — 'f\"{value}\"' — and format the value first: 'value.toFixed(2)' for fixed decimals, 'str(value).padStart(size)' for width"],
412
+ ["type", "Use 'value is Type' to test a value's type, and a 'type' declaration to name one; VelarScript has no runtime type-of function"],
413
+ ["iter", "Use a 'for' loop over the collection — 'for value in values:' — VelarScript has no 'iter'/'next' iterator pair"],
414
+ ["next", "Use a 'for' loop over the collection — 'for value in values:' — VelarScript has no 'iter'/'next' iterator pair"],
415
+ ["tuple", "Use a List — '[a, b]' — for a positional sequence, or a record — '{first: a, second: b}' — for named parts; VelarScript has no tuple type"],
416
+ ["bytes", "Import the Bytes type — 'import {Bytes} from \"velar/binary\"' — which is VelarScript's immutable byte snapshot"],
417
+ // The two capability answers. A terminal and a filesystem are target
418
+ // capabilities rather than prelude names, so the message names the module
419
+ // and says which extension carries it instead of implying a bare Core
420
+ // module can import it.
421
+ ["input", "Use velar/terminal — 'terminal.readLine(prompt)' returns the next line — a terminal is a target capability, so it arrives with the @velarscript/node extension rather than the Core prelude"],
422
+ ["open", "Use velar/fs to read or write a file, and 'using name = ...' to own a handle that must be released; a filesystem is a target capability, so it arrives with the @velarscript/node extension rather than the Core prelude"],
423
+ // D90 (coherence): the target-neutral host globals. Each of these is
424
+ // answered by a name a plain Core module can already reach, so the answer
425
+ // belongs here rather than in a target extension. `process`, `Buffer`,
426
+ // `require`, `localStorage` and the rest of the target-specific roster stay
427
+ // with the extension that owns their successor.
428
+ ["setTimeout", "Use 'await Promise.sleep(250ms)' and then run the work; VelarScript waits with a Duration rather than a callback and a millisecond number"],
429
+ ["setInterval", "Use a loop with 'await Promise.sleep(1s)' in it, or velar/task's 'task(work)' when the repetition must be cancellable; VelarScript has no callback scheduler"],
430
+ ...["clearTimeout", "clearInterval"].map((name) => [
431
+ name,
432
+ "There is no callback scheduler to clear; 'await Promise.sleep(250ms)' waits inline, and velar/task's 'task(work)' is the schedule a Cancellation can stop",
433
+ ]),
434
+ ["structuredClone", "Use 'Json.clone(value, Target)'; it validates against the runtime type as it copies"],
435
+ ["RegExp", "Use the Text pattern members — 'Text.matches', 'Text.findMatch', 'Text.findMatches', 'Text.replaceMatches' — which take the pattern as text"],
436
+ ["TextEncoder", "Use 'Text.utf8Size(value)' for the byte count, and \"velar/binary\" for the byte vocabulary itself; VelarScript does not expose the TextEncoder global"],
437
+ ["TextDecoder", "Use \"velar/binary\" for the byte vocabulary; VelarScript does not expose the TextDecoder global"],
438
+ ["URL", "Import from \"velar/url\" — 'parse', 'join', 'query', 'withQuery', 'encode' — instead of the URL global"],
439
+ ["AbortController", "Use the Cancellation that velar/task's 'task(work)' passes into its work; VelarScript cancels through that value rather than a signal object"],
440
+ ["Symbol", "VelarScript has no symbol type; use an enum for a closed set of names, or a plain string constant for a unique key"],
441
+ // `velar/worker` is a Core module, so the ambient `Worker` a host offers is
442
+ // answered once here rather than twice in the two extensions that also carry
443
+ // a worker surface.
444
+ ["Worker", "Import the builder — 'import {worker} from \"velar/worker\"' — it starts a typed worker from an entry declared in velar.json, and 'workerPool' runs several of them"],
195
445
  ...["length", "char", "slice", "trim", "lower", "upper", "startsWith", "endsWith", "includes", "split", "replace", "replaceAll", "repeat", "padStart", "padEnd", "abs", "round", "floor", "ceil", "isFinite", "isInteger"]
196
446
  .map((name) => [name, removedGlobalFunctionGuidance(name)]),
197
447
  ]);
448
+ /**
449
+ * D90 (coherence): the foreign builtins — Python's and the host's — that a
450
+ * model writes from prior knowledge. A "did you mean" is an edit-distance
451
+ * guess over names this module can actually see, and on a foreign builtin it
452
+ * is confidently wrong: `sum` earned `str`, `max` and `map` earned `Map`,
453
+ * `dir` earned `str`. docs/ai-skill.md tells the model to do exactly what the
454
+ * diagnostic says, so a wrong successor is worse than none.
455
+ *
456
+ * Every name that has a Vel answer is in `coreGlobalGuidance` above, which is
457
+ * consulted first and short-circuits the guess on its own. This roster is the
458
+ * floor under the rest: a foreign builtin with no successor reads as a bare
459
+ * unknown name and stops there. It is deliberately a suppression rather than a
460
+ * change to `uniqueNearestName`, whose threshold and roster serve the field-
461
+ * name hints too.
462
+ */
463
+ const foreignBuiltinNames = new Set([
464
+ // Python
465
+ "sum", "min", "max", "sorted", "reversed", "any", "all", "isinstance", "input", "open",
466
+ "filter", "map", "repr", "dir", "type", "id", "next", "iter", "format", "divmod", "pow",
467
+ "bytes", "tuple", "frozenset", "globals", "locals", "vars", "hasattr", "getattr", "setattr",
468
+ "callable", "issubclass", "abs", "round", "ord", "chr", "hex", "oct", "bin", "hash",
469
+ // Node and browser hosts
470
+ "setTimeout", "setInterval", "clearTimeout", "clearInterval", "structuredClone", "queueMicrotask",
471
+ "URL", "URLSearchParams", "RegExp", "TextEncoder", "TextDecoder", "AbortController", "AbortSignal",
472
+ "Symbol", "Proxy", "Reflect", "WeakMap", "WeakSet", "BigInt", "Intl", "globalThis",
473
+ "process", "Buffer", "require", "__dirname", "__filename", "module", "exports", "global",
474
+ "localStorage", "sessionStorage", "fetch", "document", "window", "navigator", "alert",
475
+ ]);
198
476
  const durationType = { kind: "named", name: "Duration" };
199
477
  const namespaceFunction = (name, parameterNames, parameters, result, requiredParameters = parameters.length) => ({ kind: "intrinsic", name, parameterNames, parameters, requiredParameters, result });
200
478
  const promiseOf = (value) => ({ kind: "promise", value });
201
479
  const jsonNamespaceType = {
202
480
  kind: "object",
203
481
  fields: new Map([
204
- ["parse", namespaceFunction("json.parse", ["text", "target"], [stringType, anyType], unknownType, 1)],
205
- ["tryParse", namespaceFunction("json.tryParse", ["text", "target", "fallback"], [stringType, anyType, anyType], unknownType, 1)],
206
- ["stringify", namespaceFunction("json.stringify", ["value", "pretty"], [anyType, { kind: "union", members: [boolType, numberType] }], stringType, 1)],
207
- ["stableStringify", namespaceFunction("json.stableStringify", ["value", "pretty"], [anyType, { kind: "union", members: [boolType, numberType] }], stringType, 1)],
208
- ["clone", namespaceFunction("json.clone", ["value", "target"], [anyType, anyType], anyType, 1)],
209
- ["isSerializable", { kind: "function", parameterNames: ["value"], parameters: [anyType], requiredParameters: 1, result: boolType }],
482
+ ["parse", namespaceFunction("json.parse", ["text", "target"], [stringType, unknownType], unknownType, 1)],
483
+ ["tryParse", namespaceFunction("json.tryParse", ["text", "target", "fallback"], [stringType, unknownType, unknownType], unknownType, 1)],
484
+ ["stringify", namespaceFunction("json.stringify", ["value", "pretty"], [unknownType, { kind: "union", members: [boolType, numberType] }], stringType, 1)],
485
+ ["stableStringify", namespaceFunction("json.stableStringify", ["value", "pretty"], [unknownType, { kind: "union", members: [boolType, numberType] }], stringType, 1)],
486
+ ["clone", namespaceFunction("json.clone", ["value", "target"], [unknownType, unknownType], unknownType, 1)],
487
+ ["isSerializable", { kind: "function", parameterNames: ["value"], parameters: [unknownType], requiredParameters: 1, result: boolType }],
210
488
  ]),
211
489
  readonlyFields: new Set(["parse", "tryParse", "stringify", "stableStringify", "clone", "isSerializable"]),
212
490
  };
@@ -273,13 +551,13 @@ const textNamespaceType = {
273
551
  const promiseNamespaceType = {
274
552
  kind: "object",
275
553
  fields: new Map([
276
- ["all", namespaceFunction("async.all", ["values"], [anyType], promiseOf(anyType))],
277
- ["race", namespaceFunction("async.race", ["values"], [{ kind: "list", element: anyType }], promiseOf(anyType))],
554
+ ["all", namespaceFunction("async.all", ["values"], [unknownType], promiseOf(unknownType))],
555
+ ["race", namespaceFunction("async.race", ["values"], [{ kind: "list", element: unknownType }], promiseOf(unknownType))],
278
556
  ["sleep", { kind: "function", parameterNames: ["duration"], parameters: [durationType], requiredParameters: 1, result: promiseOf(nullType) }],
279
- ["timeout", namespaceFunction("async.timeout", ["value", "duration", "message"], [promiseOf(anyType), durationType, stringType], promiseOf(anyType), 2)],
280
- ["retry", namespaceFunction("async.retry", ["task", "attempts", "delay"], [anyType, numberType, durationType], promiseOf(anyType), 1)],
281
- ["map", namespaceFunction("async.map", ["values", "worker", "concurrency"], [{ kind: "list", element: anyType }, anyType, numberType], promiseOf({ kind: "list", element: anyType }), 2)],
282
- ["series", namespaceFunction("async.series", ["tasks"], [{ kind: "list", element: anyType }], promiseOf({ kind: "list", element: anyType }))],
557
+ ["timeout", namespaceFunction("async.timeout", ["value", "duration", "message"], [promiseOf(unknownType), durationType, stringType], promiseOf(unknownType), 2)],
558
+ ["retry", namespaceFunction("async.retry", ["task", "attempts", "delay"], [unknownType, numberType, durationType], promiseOf(unknownType), 1)],
559
+ ["map", namespaceFunction("async.map", ["values", "worker", "concurrency"], [{ kind: "list", element: unknownType }, unknownType, numberType], promiseOf({ kind: "list", element: unknownType }), 2)],
560
+ ["series", namespaceFunction("async.series", ["tasks"], [{ kind: "list", element: unknownType }], promiseOf({ kind: "list", element: unknownType }))],
283
561
  ]),
284
562
  readonlyFields: new Set(["all", "race", "sleep", "timeout", "retry", "map", "series"]),
285
563
  };
@@ -344,8 +622,9 @@ const coreVocabularyTypes = {
344
622
  // `values.map(str)` — is checked against the same whitelist the direct
345
623
  // call form uses instead of executing a 'toString' hook.
346
624
  str: { kind: "function", parameterNames: ["value"], parameters: [textConvertibleType], requiredParameters: 1, result: stringType },
347
- // `print` inspects any value by contract and keeps the `any` domain.
348
- print: { kind: "function", parameterNames: ["value"], parameters: [anyType], requiredParameters: 1, result: nullType },
625
+ // `print` inspects any value by contract; its domain is the top type for
626
+ // assignment targets (D90 R17 keeps `any` for boundary declarations only).
627
+ print: { kind: "function", parameterNames: ["value"], parameters: [unknownType], requiredParameters: 1, result: nullType },
349
628
  // D47 rule 81: equals(a, b) — deep structural comparison over data.
350
629
  // Pure computation, so it lives in the prelude beside str/print; the
351
630
  // call site owns the domain checks (inferEqualsCall).
@@ -455,10 +734,120 @@ function externClassContract(info) {
455
734
  methodEntries(info.staticMethods),
456
735
  ]);
457
736
  }
737
+ // D89 A2's two rosters. They are deliberately short: every name here is one a
738
+ // Python author reaches for without thinking, and a name that has to be argued
739
+ // for is a name the advisory would be guessing about.
740
+ const loopIndexSlotNames = new Set(["i", "idx", "index", "pos", "position"]);
741
+ const loopValueSlotNames = new Set(["v", "value", "item", "el", "element"]);
742
+ /**
743
+ * The singular of the iterated collection's own name, so `for i, user in
744
+ * users` reads as the same swap as `for i, v in users`. Only a plain name is
745
+ * read; an arbitrary expression has no name to make singular.
746
+ */
747
+ function singularIterableName(iterable) {
748
+ const name = iterable.kind === "IdentifierExpression" ? iterable.name
749
+ : iterable.kind === "MemberExpression" ? iterable.property
750
+ : null;
751
+ if (name === null || !name.endsWith("s") || name.endsWith("ss"))
752
+ return null;
753
+ if (name.endsWith("ies"))
754
+ return `${name.slice(0, -3)}y`;
755
+ if (/(?:ch|sh|[sxz])es$/u.test(name))
756
+ return name.slice(0, -2);
757
+ return name.slice(0, -1);
758
+ }
759
+ /**
760
+ * D85 rule 207: does this type still carry an unsettled collection — a List or
761
+ * Set element, or a Map's key and value together, that stayed `unknown`?
762
+ *
763
+ * This is the gate on the walk below, and it is what makes rule 208's boundary
764
+ * fall out of the semantics instead of out of a syntax list. `Set().size` is a
765
+ * number: nothing the empty `Set()` failed to say reaches the name, so the walk
766
+ * never descends to it. `[["a"], []]` is a `List<List<string>>`: the sibling
767
+ * element already said what the empty one holds, so there is nothing left to
768
+ * report. Only a hole that survives all the way out to the binding is a hole
769
+ * the author has to close.
770
+ */
771
+ function carriesUnsettledCollection(type, seen = new Set()) {
772
+ if (seen.has(type))
773
+ return false;
774
+ seen.add(type);
775
+ switch (type.kind) {
776
+ case "list":
777
+ case "set":
778
+ return type.element.kind === "unknown" || carriesUnsettledCollection(type.element, seen);
779
+ case "map":
780
+ // A Map settles when either half does: `Map<string, unknown>` says its
781
+ // keys are strings, and only `Map()` with nothing at all is unsettled.
782
+ return (type.key.kind === "unknown" && type.value.kind === "unknown")
783
+ || carriesUnsettledCollection(type.key, seen)
784
+ || carriesUnsettledCollection(type.value, seen);
785
+ case "optional":
786
+ return carriesUnsettledCollection(type.inner, seen);
787
+ case "record":
788
+ case "promise":
789
+ return carriesUnsettledCollection(type.value, seen);
790
+ case "object":
791
+ return [...type.fields.values()].some((field) => carriesUnsettledCollection(field, seen));
792
+ case "union":
793
+ return type.members.some((member) => carriesUnsettledCollection(member, seen));
794
+ default:
795
+ return false;
796
+ }
797
+ }
798
+ /**
799
+ * D85 rule 207: the sub-expressions whose value becomes part of this
800
+ * expression's value, so each of them is a position that has to say what an
801
+ * empty collection written there holds.
802
+ *
803
+ * A receiver and an argument are here, but the caller only asks for them once
804
+ * `carriesUnsettledCollection` has said the enclosing value still has the hole
805
+ * in it — so `const a = [].copy()` and `const a = id([])` report at the `[]`
806
+ * that made the hole, while `print(Set().size)` and `const n = Set().size`
807
+ * never reach the receiver at all. That is rule 208 stated as a property of the
808
+ * value rather than as a list of node kinds.
809
+ */
810
+ function settlingValuePositions(expression) {
811
+ switch (expression.kind) {
812
+ case "ConditionalExpression":
813
+ return [expression.thenValue, expression.elseValue];
814
+ case "ListExpression":
815
+ return expression.elements.map((element) => element.kind === "SpreadExpression" ? element.value : element);
816
+ case "ObjectExpression":
817
+ return expression.properties.map((entry) => entry.value);
818
+ case "BinaryExpression":
819
+ return expression.operator === "??" ? [expression.left, expression.right] : [];
820
+ case "CallExpression":
821
+ return [
822
+ ...(expression.callee.kind === "MemberExpression" ? [expression.callee.object] : []),
823
+ ...expression.arguments.map((argument) => argument.kind === "SpreadExpression" ? argument.value : argument),
824
+ ];
825
+ case "MemberExpression":
826
+ return [expression.object];
827
+ case "IndexExpression":
828
+ return [expression.object];
829
+ default:
830
+ return [];
831
+ }
832
+ }
458
833
  export class Analyzer {
459
834
  diagnostics = [];
835
+ advisories = [];
836
+ advisedIdentities = new Set();
460
837
  scopes = [new Map()];
461
838
  memberNarrowings = [new Map()];
839
+ /** Per scope depth, the names a narrowing has written there; see `narrowingsForVisibleBindings`. */
840
+ narrowedNames = [new Set()];
841
+ /** How many loop back-edge passes are running; see `reanalyzeLoopBackEdge`. */
842
+ loopReanalysisDepth = 0;
843
+ /** The "did you mean" roster, and the names each scope depth contributed to it. */
844
+ nearestNames = new NearestNameRoster();
845
+ scopedNames = [[]];
846
+ nearestNamesSeeded = false;
847
+ /** Per scope depth, the bindings flow analysis has written; see `snapshotFlowFacts`. */
848
+ flowTouched = [new Set()];
849
+ /** What each of those held before its first write, or null for a shadow born mid-flow. */
850
+ flowOrigins = new Map();
462
851
  namedTypes = new Map();
463
852
  namedTypeReadonlyFields = new Map();
464
853
  namedTypeIdentities = new Map();
@@ -509,12 +898,10 @@ export class Analyzer {
509
898
  classDisposeChains = new Map();
510
899
  /** D68 rule 177: expression spans a consumer iterates through `@iterate:`. */
511
900
  iterationContracts = new Set();
512
- /**
513
- * D68 rule 177: the function depth of each `@iterate:` body being analyzed.
514
- * A nested arrow inside the block is an ordinary callable and keeps the
515
- * ordinary advice, so the contract's own message needs the exact depth.
516
- */
517
- iterateContractDepths = [];
901
+ /** D90 R18: `async for` statements pulling a declared asynchronous `@iterate:`. */
902
+ asyncIterationStatements = new Set();
903
+ /** D90 R18: `@iterate:` blocks that are the asynchronous pull form, by keyword span. */
904
+ asyncIterateBlocks = new Set();
518
905
  /** D51 rule 101: arrows that read a `using`-owned binding, by arrow span. */
519
906
  arrowOwnedCaptures = new Map();
520
907
  arrowCaptureFrames = [];
@@ -591,6 +978,22 @@ export class Analyzer {
591
978
  inferredFunctionResultSeeds;
592
979
  inferredFunctionResultTypes = new Map();
593
980
  finalizeFunctionResultInference;
981
+ /**
982
+ * D85 rule 209: one mistake is reported once. A reported VEL4039 hands its
983
+ * position `invalidType`, and an invalid body-inferred result is otherwise a
984
+ * convergence failure — so the four collections below carry the *reported*
985
+ * hole forward, and only that hole, to the place VEL4025 is decided.
986
+ * `reportedCollectionHoles` are the names bound to one, `reportedResultHoles`
987
+ * the local function results that are one, `functionResultKeys` maps a
988
+ * callable name to the result a call to it reaches, and
989
+ * `deferredConvergenceReports` holds the reports whose answer needs a callee
990
+ * that may be declared further down the module.
991
+ */
992
+ reportedCollectionHoles = new Set();
993
+ bindingHoleCauses = new Map();
994
+ reportedResultHoles = new Set();
995
+ functionResultKeys = new Map();
996
+ deferredConvergenceReports = [];
594
997
  logicalConditionNarrowings = new Map();
595
998
  privateFields = new Map();
596
999
  privateGetters = new Map();
@@ -624,8 +1027,27 @@ export class Analyzer {
624
1027
  /** Namespace import locals by name, known before signature validation runs (ENM-I9 teaching). */
625
1028
  namespaceImportLocals = new Map();
626
1029
  initializationImportReadSites = new Map();
1030
+ /**
1031
+ * D31 item 23's recorded residual: a top-level call of a module-local
1032
+ * function runs that body during module evaluation, so a read of an imported
1033
+ * binding inside it is an initialization-position read reached through one
1034
+ * hop. The stack is the function frame a deferred read belongs to; the map
1035
+ * is that frame by the name it was bound to, so a call resolves to it after
1036
+ * the whole module is analyzed and hoisting order stops mattering.
1037
+ */
1038
+ deferredReadFrames = [];
1039
+ localFunctionFrames = new Map();
1040
+ arrowDeferredFrames = new Map();
1041
+ initializationLocalCalls = [];
627
1042
  /** Local class bindings mapped to the source offset where their `class` statement evaluates (CLS-D8). */
628
1043
  hoistedClassDeclarations = new Map();
1044
+ /**
1045
+ * D90 R12: public class members whose omitted annotation inferred an output
1046
+ * `any`. Whether the member is at an export position depends on whether a
1047
+ * consumer can reach its class, which is not settled until the whole module
1048
+ * is analyzed, so the report waits for reportExportPositionAny.
1049
+ */
1050
+ exportPositionCandidates = [];
629
1051
  /** Module-scope names bound to runtime Type objects (local and imported); see LoweringHints.runtimeTypeObjectNames. */
630
1052
  runtimeTypeObjectNames = new Set();
631
1053
  staticFieldInitialization = null;
@@ -690,16 +1112,20 @@ export class Analyzer {
690
1112
  staticGetters: new Set(),
691
1113
  staticMethods: new Map(),
692
1114
  });
693
- // ENM-U4 + COL-U5: the three compiler-raised error types are nameable —
1115
+ // ENM-U4 + COL-U5: the compiler-raised error types are nameable —
694
1116
  // catchable, `is`-narrowable, and constructible — wired exactly like
695
1117
  // Error. ValidationError additionally carries the failure detail its
696
- // parse sites report (path, field, reason).
1118
+ // parse sites report (path, field, reason). AssertionError joins the
1119
+ // roster because the charter already promises it does: "A `catch` block
1120
+ // still receives all three, because a `catch` is explicit: the author
1121
+ // wrote code to handle it, and `is` names which one it was."
697
1122
  const builtinErrorDetails = [
698
1123
  ["ValidationError", [
699
1124
  ["path", { mutable: false, type: optionalOf(stringType) }],
700
1125
  ["field", { mutable: false, type: optionalOf(stringType) }],
701
1126
  ["reason", { mutable: false, type: optionalOf(stringType) }],
702
1127
  ]],
1128
+ ["AssertionError", []],
703
1129
  ["NarrowingError", []],
704
1130
  ["IndexError", []],
705
1131
  // D50 rule 89: the capability failures a caller recovers from
@@ -826,18 +1252,38 @@ export class Analyzer {
826
1252
  }
827
1253
  return this.globalGuidance.get(name);
828
1254
  }
1255
+ /**
1256
+ * D90 (coherence): the one report an unresolved name earns, wherever it was
1257
+ * written. A reserved global names the module that replaced it, a foreign
1258
+ * builtin with no successor stops at the bare message rather than guessing,
1259
+ * and everything else may carry the nearest visible name. Both unresolved-
1260
+ * name sites reach this, because `exports = {run: run}` is the same mistake
1261
+ * as `const value = exports` and used to earn a strictly worse answer for
1262
+ * standing on the left of the `=`.
1263
+ */
1264
+ reportUnresolvedName(name, span) {
1265
+ const guidance = this.guidanceForGlobal(name);
1266
+ const nearest = guidance || foreignBuiltinNames.has(name) ? null : this.nearestVisibleBindingName(name);
1267
+ this.diagnostics.push(diagnostic(guidance ? "VEL3008" : "VEL3001", guidance ?? `Unknown name '${name}'${nearest ? `; did you mean '${nearest}'?` : ""}`, span));
1268
+ }
829
1269
  nearestVisibleBindingName(name) {
830
- const candidates = new Set([
831
- ...Object.keys(coreVocabularyTypes),
832
- ...this.extensionGlobals.keys(),
833
- ...this.importBindings.keys(),
834
- "Map", "Set", "Error", "ValidationError", "NarrowingError", "IndexError",
835
- ...VELAR_HOST_ERROR_NAMES,
836
- ]);
837
- for (const scope of this.scopes)
838
- for (const candidate of scope.keys())
839
- candidates.add(candidate);
840
- return uniqueNearestName(name, candidates);
1270
+ if (!this.nearestNamesSeeded) {
1271
+ this.nearestNamesSeeded = true;
1272
+ for (const candidate of [
1273
+ ...Object.keys(coreVocabularyTypes),
1274
+ ...this.extensionGlobals.keys(),
1275
+ ...this.importBindings.keys(),
1276
+ "Map", "Set", "Error", "ValidationError", "AssertionError", "NarrowingError", "IndexError",
1277
+ ...VELAR_HOST_ERROR_NAMES,
1278
+ ])
1279
+ this.nearestNames.add(candidate);
1280
+ }
1281
+ return this.nearestNames.nearest(name);
1282
+ }
1283
+ /** Files a scope's name in the "did you mean" roster and takes it back out when the scope exits. */
1284
+ recordScopedName(name) {
1285
+ this.nearestNames.add(name);
1286
+ this.scopedNames.at(-1).push(name);
841
1287
  }
842
1288
  modulePath;
843
1289
  importBindings;
@@ -873,14 +1319,31 @@ export class Analyzer {
873
1319
  for (const statement of program.body) {
874
1320
  this.analyzeStatement(statement);
875
1321
  }
1322
+ // D90 R12 reports last for the same reason: whether a class member is at
1323
+ // an export position is a question about the module's whole export
1324
+ // surface, which no single declaration can answer as it is analyzed.
1325
+ this.reportExportPositionAny(program);
876
1326
  // D52 rules 114/116: both namespace migrations report last, because both
877
1327
  // rewrites need the whole module before they can be written down — one has
878
1328
  // to know every name the new import would have to clear, and the other has
879
1329
  // to know every read the retiring import leaves behind.
880
1330
  this.reportRetiredNamespaceUses(program);
881
1331
  this.reportPermanentNamespaceImports(program);
1332
+ this.reportPermanentNamespaceReExports(program);
1333
+ // D85 rule 209 reports last for the same reason: a hole reaches a caller
1334
+ // through a callee the module may not declare until later, so the second
1335
+ // report is deleted once every hole in the module is on record.
1336
+ this.resolveDeferredConvergenceReports();
882
1337
  return this.diagnostics;
883
1338
  }
1339
+ /**
1340
+ * D89: the advisories this analysis raised. `analyze` keeps returning the
1341
+ * diagnostics alone, so the caller reads the two channels separately and the
1342
+ * cursor arithmetic over `this.diagnostics` stays exact.
1343
+ */
1344
+ analyzedAdvisories() {
1345
+ return this.advisories;
1346
+ }
884
1347
  registerExternTypeImports(program) {
885
1348
  const classesBySource = new Map();
886
1349
  for (const statement of program.body) {
@@ -1113,6 +1576,12 @@ export class Analyzer {
1113
1576
  }
1114
1577
  else if (statement.kind === "FunctionDeclaration") {
1115
1578
  this.declareBinding(statement.name, false, this.functionType(statement), statement.span);
1579
+ // D85 rule 209: the result a call to this name reaches, recorded before
1580
+ // any body is analyzed so a call to a function declared further down
1581
+ // the module still resolves to it.
1582
+ const callable = this.scopes.at(-1)?.get(statement.name);
1583
+ if (callable)
1584
+ this.functionResultKeys.set(callable, this.functionResultKey(statement));
1116
1585
  this.predeclared.add(statement);
1117
1586
  }
1118
1587
  else if (this.predeclareExtensionStatement(statement)) {
@@ -1357,6 +1826,8 @@ export class Analyzer {
1357
1826
  usingDisposals: this.usingDisposals,
1358
1827
  classDisposeChains: this.classDisposeChains,
1359
1828
  iterationContracts: this.iterationContracts,
1829
+ asyncIterationStatements: this.asyncIterationStatements,
1830
+ asyncIterateBlocks: this.asyncIterateBlocks,
1360
1831
  moduleTopLevelHostCalls: this.moduleTopLevelHostCalls,
1361
1832
  };
1362
1833
  }
@@ -2310,8 +2781,10 @@ export class Analyzer {
2310
2781
  // from the body — the same shape as an omitted function result, and it
2311
2782
  // rides the same seeded convergence passes. The shape pre-pass seeds
2312
2783
  // what the previous pass learned so a use written above the class sees
2313
- // the real collection instead of the placeholder.
2314
- ...(statement.iterate ? { iterate: this.seededIterationSource(statement.iterate) } : {}),
2784
+ // the real collection instead of the placeholder. D90 R18: an optional
2785
+ // seed is the asynchronous pull form — a collection can never validate
2786
+ // to `T?` — so the seed's shape says which field it belongs in.
2787
+ ...(statement.iterate ? this.seededIterationInfo(statement.iterate) : {}),
2315
2788
  parameters: statement.parameters.map((parameter) => this.resolveValidatedAnnotation(parameter.type)),
2316
2789
  parameterNames: statement.parameters.map((parameter) => parameter.name),
2317
2790
  requiredParameters: statement.parameters.filter((parameter) => !parameter.defaultValue).length,
@@ -2596,8 +3069,26 @@ export class Analyzer {
2596
3069
  const contract = annotationValid ? annotated ?? inferredStorage : invalidType;
2597
3070
  if (annotationValid)
2598
3071
  this.requireAssignable(actual, declared, statement.initializer.span);
2599
- this.requireSettledCollectionElement(statement.initializer, declared, annotated !== null);
2600
- this.declarePattern(statement.pattern, statement.binding === "let", declared, contract);
3072
+ // D85 rule 209: the construction that just reported is invalid from
3073
+ // here on. Binding the name to the hole instead would reproduce
3074
+ // `Cannot assign List<unknown> to ...` on a later line that has no
3075
+ // `[]` in it — the second, contradicting report the ruling deletes.
3076
+ // D90 R12: `any` may not cross a module boundary. The written spelling
3077
+ // is already refused by validateTypeReference above, so only the
3078
+ // inferred one reaches here — and that asymmetry was the defect, since
3079
+ // the spelling that got refused is the honest one. Checking the
3080
+ // settled type before declarePattern covers every pattern shape at
3081
+ // once, including `export const {a, b} = thing`.
3082
+ if (statement.exported && annotationValid && typeContainsAnyOutput(declared)) {
3083
+ const exported = [];
3084
+ this.collectPatternNames(statement.pattern, (name) => exported.push(name));
3085
+ this.reportExportedAny(exported, statement.span);
3086
+ }
3087
+ const unsettled = this.requireSettledCollectionElement(statement.initializer, declared, annotated !== null);
3088
+ this.declarePattern(statement.pattern, statement.binding === "let", unsettled ? invalidType : declared, unsettled ? invalidType : contract);
3089
+ if (annotated === null)
3090
+ this.recordBindingHoleSource(statement.pattern, statement.initializer, unsettled);
3091
+ this.claimArrowDeferredFrame(statement.pattern, statement.initializer);
2601
3092
  // D51 rule 101: an alias of an owned handle — or a closure over one —
2602
3093
  // is the same resource under a second name, so it inherits the
2603
3094
  // ownership and the escape check follows it.
@@ -2669,10 +3160,35 @@ export class Analyzer {
2669
3160
  this.asyncResolvedValues.add(spanIdentity(statement.value.span));
2670
3161
  }
2671
3162
  }
2672
- if (this.unreachableDiagnosticDepth === 0)
2673
- (inferredReturns ?? returnContext?.observedReturns)?.push(returned);
2674
- if (inferredReturns)
3163
+ if (inferredReturns) {
3164
+ // D85 rule 207: a body-inferred result has no annotation to settle
3165
+ // an empty collection, and the name that reads it can be in another
3166
+ // module — `export def make(): return []` publishes `List<unknown>`
3167
+ // across the interface. An annotated result is a contextual type and
3168
+ // settles the construction before it ever gets here. Rule 209: a
3169
+ // reported hole contributes `invalidType`, so the caller's
3170
+ // `const values: List<string> = make()` does not report the same
3171
+ // mistake a second time in a line that has no `[]` in it.
3172
+ const unsettled = statement.value !== null && this.requireSettledCollectionElement(statement.value, actual, false);
3173
+ if (returnContext) {
3174
+ // The hole reaches the result through whatever the author wrote
3175
+ // between it and the `return` — a name, a chain of them, or a call
3176
+ // to a local function whose own VEL4039 already reported. Each of
3177
+ // those is the same one mistake, so the convergence failure it
3178
+ // produces below is not a second problem to report.
3179
+ const causes = returnContext.resultHoleCauses ?? new Set();
3180
+ const carried = statement.value !== null && this.collectResultHoleSources(statement.value, causes);
3181
+ if (causes.size > 0)
3182
+ returnContext.resultHoleCauses = causes;
3183
+ if (unsettled || carried)
3184
+ returnContext.unsettledResult = true;
3185
+ }
3186
+ if (this.unreachableDiagnosticDepth === 0)
3187
+ inferredReturns.push(unsettled ? invalidType : returned);
2675
3188
  break;
3189
+ }
3190
+ if (this.unreachableDiagnosticDepth === 0)
3191
+ returnContext?.observedReturns?.push(returned);
2676
3192
  this.requireAssignable(returned, expected, statement.value?.span ?? statement.span);
2677
3193
  break;
2678
3194
  }
@@ -2976,11 +3492,10 @@ export class Analyzer {
2976
3492
  && this.builtinValueReferences.get(spanIdentity(statement.iterable.callee.span)) === "range") {
2977
3493
  this.nativeRangeForStatements.add(statement.span.start);
2978
3494
  }
2979
- // D68 rule 177: `async for` is deliberately outside the contract. It
2980
- // pulls a capability handle a resource with a lifetime that `using`
2981
- // owns and a class that answered it would make "does this need
2982
- // releasing?" undecidable, so the projection happens on the
2983
- // synchronous side only and `async for` keeps refusing user types.
3495
+ // D68 rule 177 + D90 R18: the eight plain consumers project through
3496
+ // the synchronous `@iterate:` answer; `async for` reads the
3497
+ // asynchronous form's declaration inside asyncPullElementType, so the
3498
+ // asynchronous side takes the operand unprojected.
2984
3499
  const iterable = statement.asynchronous ? inferredIterable : this.iterationSource(statement.iterable, inferredIterable);
2985
3500
  if (!statement.asynchronous
2986
3501
  && (iterable.kind === "list" || iterable.kind === "map" || iterable.kind === "set" || iterable.kind === "record" || iterable.kind === "string")) {
@@ -3001,7 +3516,7 @@ export class Analyzer {
3001
3516
  ? this.invalidExtensionAwaitMessage() ?? "'async for' is not valid in this synchronous extension context"
3002
3517
  : "'async for' can only be used in an async function or at module scope", statement.span));
3003
3518
  }
3004
- first = this.asyncPullElementType(iterable, statement.iterable.span);
3519
+ first = this.asyncPullElementType(iterable, statement.iterable.span, statement.span.start);
3005
3520
  second = numberType;
3006
3521
  this.asyncForStatements.add(statement.span.start);
3007
3522
  }
@@ -3020,6 +3535,8 @@ export class Analyzer {
3020
3535
  : `Cannot iterate over ${describeType(iterable)}${this.iterationGuidance(iterable)}`, statement.iterable.span);
3021
3536
  }
3022
3537
  }
3538
+ if (!statement.asynchronous)
3539
+ this.adviseSwappedLoopSlots(statement, iterable);
3023
3540
  const baseline = this.snapshotFlowFacts();
3024
3541
  this.loopFlowContexts.push({ baseline, visible: this.visibleBindings(), carried: [], backEdges: [], breakFacts: [], sawBreak: false });
3025
3542
  const diagnosticStart = this.diagnostics.length;
@@ -3095,7 +3612,7 @@ export class Analyzer {
3095
3612
  // FLW-S1: a loop the body can re-enter tests its condition again in
3096
3613
  // the back-edge state, so the exit fact is what both tests agree on.
3097
3614
  let repeatedFalsy = null;
3098
- const repeatedFlow = this.reanalyzeLoopBackEdge(baseline, loopFlow.visible, backEdges, statement.body, diagnosticStart, () => {
3615
+ const backEdgePass = this.reanalyzeLoopBackEdge(baseline, loopFlow.visible, backEdges, statement.body, diagnosticStart, () => {
3099
3616
  this.clearCachedFlowTypesInSpan(statement.condition.span);
3100
3617
  const repeatedCondition = this.inferExpression(statement.condition);
3101
3618
  this.requireCondition(repeatedCondition, statement.condition);
@@ -3124,15 +3641,19 @@ export class Analyzer {
3124
3641
  // that always returns. A break can leave while the condition still
3125
3642
  // holds, so one break drops the fact entirely.
3126
3643
  if (!loopFlow.sawBreak) {
3127
- this.persistNarrowings(repeatedFalsy === null ? falsy : this.joinedNarrowings(falsy, repeatedFalsy));
3644
+ // A widened exit confirmed nothing about the back edge, so it keeps
3645
+ // nothing: the condition's fact holds only if the second test agrees.
3646
+ this.persistNarrowings(backEdgePass.widened
3647
+ ? new Map()
3648
+ : repeatedFalsy === null ? falsy : this.joinedNarrowings(falsy, repeatedFalsy));
3128
3649
  }
3129
3650
  else if (statement.condition.kind === "LiteralExpression" && statement.condition.value === true) {
3130
3651
  // FLW-N6: `while true:` has no failing condition, so its breaks are
3131
3652
  // its only exits, and what every one of them proves holds after the
3132
3653
  // loop. A loop whose condition can also fail keeps nothing: that
3133
3654
  // exit proves none of it.
3134
- const breakFacts = [...loopFlow.breakFacts, ...(repeatedFlow?.breakFacts ?? [])];
3135
- if (breakFacts.length > 0)
3655
+ const breakFacts = [...loopFlow.breakFacts, ...(backEdgePass.repeated?.breakFacts ?? [])];
3656
+ if (breakFacts.length > 0 && !backEdgePass.widened)
3136
3657
  this.persistNarrowings(this.commonNarrowings(breakFacts));
3137
3658
  }
3138
3659
  break;
@@ -3254,6 +3775,33 @@ export class Analyzer {
3254
3775
  break;
3255
3776
  }
3256
3777
  }
3778
+ /**
3779
+ * D89 A2: the two-slot `for` over a List, Set, or string binds
3780
+ * `value, index`, which matches JavaScript's `forEach((v, i) => …)` and
3781
+ * inverts Python's `enumerate`. Python's own spelling is already a loud
3782
+ * error, so nothing silent comes from it; the silence happens when a model
3783
+ * writes `for i, v in nums`, a hybrid neither language has, and both names
3784
+ * quietly hold the other one's value.
3785
+ *
3786
+ * Both rosters must hit. One name alone proves nothing — `for index, total
3787
+ * in scores` may be counting exactly what it says — and a wrong guess here
3788
+ * would tell a correct author to break working code. The value slot also
3789
+ * accepts the singular of the collection's own name, because `for i, user
3790
+ * in users` is the same reflex spelled from the data instead of a letter.
3791
+ */
3792
+ adviseSwappedLoopSlots(statement, iterable) {
3793
+ if (iterable.kind !== "list" && iterable.kind !== "set" && iterable.kind !== "string")
3794
+ return;
3795
+ const indexSlot = statement.pattern;
3796
+ const valueSlot = statement.secondPattern;
3797
+ if (indexSlot.kind !== "NameBindingPattern" || valueSlot?.kind !== "NameBindingPattern")
3798
+ return;
3799
+ if (!loopIndexSlotNames.has(indexSlot.name))
3800
+ return;
3801
+ if (!loopValueSlotNames.has(valueSlot.name) && valueSlot.name !== singularIterableName(statement.iterable))
3802
+ return;
3803
+ this.advise("A2", `A two-slot 'for' binds 'value, index', so '${indexSlot.name}' receives the element and '${valueSlot.name}' receives the position; write 'for ${valueSlot.name}, ${indexSlot.name} in ...' to bind them the way the names read`, span(indexSlot.span.start, valueSlot.span.end), mechanicalEdits([{ span: indexSlot.span, text: valueSlot.name }, { span: valueSlot.span, text: indexSlot.name }], `Swap '${indexSlot.name}' and '${valueSlot.name}'`));
3804
+ }
3257
3805
  // D32 item 30: a Promise-typed expression statement is a floating promise —
3258
3806
  // nothing waits for it and nothing owns its failure. The diagnostic teaches
3259
3807
  // both current spellings: 'await' waits, the 'async' statement detaches.
@@ -3318,7 +3866,19 @@ export class Analyzer {
3318
3866
  // The operation tables already prove which method the call lowered to, so
3319
3867
  // the check needs no user-function purity analysis.
3320
3868
  checkDiscardedPureResult(expression) {
3321
- if (expression.kind !== "CallExpression" || expression.callee.kind !== "MemberExpression")
3869
+ if (expression.kind !== "CallExpression")
3870
+ return;
3871
+ // D29 item 14's own rationale reaches `expect(...)` with no matcher:
3872
+ // building an expectation object and never asking it anything throws the
3873
+ // only product away, and the statement reads as an assertion that passes.
3874
+ // D30 item 17's general CallExpression exemption is untouched — a bare
3875
+ // call may perform an effect — because `testExpectOperands` already
3876
+ // proves this one call lowered to `test.expect`, which performs none.
3877
+ if (this.testExpectOperands.has(spanIdentity(expression.span))) {
3878
+ this.diagnostics.push(diagnostic("VEL4030", "'expect(...)' builds an expectation and asserts nothing on its own; add a matcher such as '.toBe(expected)'", expression.span));
3879
+ return;
3880
+ }
3881
+ if (expression.callee.kind !== "MemberExpression")
3322
3882
  return;
3323
3883
  const collectionOperation = this.collectionCalls.get(expression.callee.span.end);
3324
3884
  const primitiveOperation = this.primitiveCalls.get(expression.callee.span.end);
@@ -3403,11 +3963,13 @@ export class Analyzer {
3403
3963
  const baseName = statement.base?.name ?? null;
3404
3964
  if (baseName) {
3405
3965
  const baseBinding = this.lookup(baseName) ?? this.builtin(baseName);
3406
- if (baseName === "ValidationError" || baseName === "NarrowingError" || baseName === "IndexError"
3966
+ if (baseName === "ValidationError" || baseName === "AssertionError" || baseName === "NarrowingError"
3967
+ || baseName === "IndexError"
3407
3968
  || VELAR_HOST_ERROR_NAMES.includes(baseName)) {
3408
3969
  // The compiler-raised error types are leaf contracts: user subclasses
3409
- // would dilute what a caught ValidationError/NarrowingError/IndexError
3410
- // proves. Extend Error for custom hierarchies.
3970
+ // would dilute what a caught ValidationError/AssertionError/
3971
+ // NarrowingError/IndexError proves. Extend Error for custom
3972
+ // hierarchies.
3411
3973
  this.typeError(`The builtin error type '${baseName}' cannot be extended; extend Error and declare your own fields`, statement.base.span);
3412
3974
  }
3413
3975
  else if (baseBinding?.type.kind === "classConstructor" && !this.classes.has(baseName)
@@ -3437,7 +3999,18 @@ export class Analyzer {
3437
3999
  this.enterScope();
3438
4000
  this.flowFrameDepth += 1;
3439
4001
  this.superMemberContext = "instance";
3440
- for (const parameter of statement.parameters) {
4002
+ for (const [index, parameter] of statement.parameters.entries()) {
4003
+ // D89 (message correction): `constructor(self, ...)` is the same Python
4004
+ // receiver reflex a method's `self` parameter is, and it used to land on
4005
+ // the bare reserved-binding refusal, which names no fix. A field-binding
4006
+ // spelling (`const self`) is excluded because its `const`/`private`
4007
+ // prefix sits outside the parameter span, so the deletion this report
4008
+ // carries would leave the prefix stranded — and D38 §48 admits only
4009
+ // rewrites that land on working source.
4010
+ if (parameter.name === "self" && !parameter.rest && parameter.binding === null && statement.initialization !== null) {
4011
+ this.reportImplicitSelfParameter(statement.parameters, index);
4012
+ continue;
4013
+ }
3441
4014
  const type = this.resolveAnnotation(parameter.type);
3442
4015
  const valid = parameter.type ? this.validateTypeReference(parameter.type) : true;
3443
4016
  if (parameter.defaultValue && valid) {
@@ -4058,14 +4631,30 @@ export class Analyzer {
4058
4631
  seededIterationSource(block) {
4059
4632
  return this.inferredFunctionResultSeeds.get(this.iterationResultKey(block)) ?? inferredResultPlaceholderType;
4060
4633
  }
4634
+ /**
4635
+ * D90 R18: the seed routed to the field its form owns. An optional seed can
4636
+ * only have come from the asynchronous pull form — the synchronous form
4637
+ * never validates to `T?` — so the shape pre-pass reads the form off the
4638
+ * seed the previous convergence pass learned.
4639
+ */
4640
+ seededIterationInfo(block) {
4641
+ const seed = this.seededIterationSource(block);
4642
+ const expanded = this.expandAliases(seed);
4643
+ return expanded.kind === "optional" ? { iterateAsync: expanded.inner } : { iterate: seed };
4644
+ }
4061
4645
  /**
4062
4646
  * `@iterate:` answers the compiler's question "what does
4063
- * iterating you mean?" with a collection the language already iterates. It
4064
- * shares `@dispose:`'s compiler-name path, then supplies its own role: it is
4065
- * a contract, not a method, it produces a value, and it may not `await`,
4066
- * because every one of the eight consumers reads it synchronously.
4647
+ * iterating you mean?". It shares `@dispose:`'s compiler-name path, then
4648
+ * supplies its own role: it is a contract, not a method, and it produces a
4649
+ * value. D90 R18 gives it two forms, told apart by the answer's shape the
4650
+ * same way `@dispose:`'s async-ness is read off its own body: the
4651
+ * synchronous form answers a collection the language already iterates and
4652
+ * the eight plain consumers read it once; the asynchronous pull form
4653
+ * answers `T?` — `async for` drives it once per element, it may await, and
4654
+ * null is exhaustion.
4067
4655
  */
4068
4656
  analyzeClassIterate(statement, block, baseName) {
4657
+ const awaits = blockContainsDirectAwait(block.body, (expression, contains) => this.extensionExpressionContainsDirectAwait(expression, contains), (owned, containsExpression, containsBlock) => this.extensionStatementContainsDirectAwait(owned, containsExpression, containsBlock));
4069
4658
  this.enterScope();
4070
4659
  this.flowFrameDepth += 1;
4071
4660
  this.functionDepth += 1;
@@ -4077,13 +4666,14 @@ export class Analyzer {
4077
4666
  const previousSuperMemberContext = this.superMemberContext;
4078
4667
  this.currentClass = statement.name;
4079
4668
  this.superMemberContext = "instance";
4080
- this.asynchronousFunctions.push(false);
4081
- this.iterateContractDepths.push(this.functionDepth);
4669
+ // A block that awaits is the asynchronous form (the same reading
4670
+ // `@dispose:` gets), so its awaits are legal; a block without one has
4671
+ // nothing for the flag to allow.
4672
+ this.asynchronousFunctions.push(awaits);
4082
4673
  const inferredReturns = [];
4083
4674
  this.returnContexts.push({ expected: unknownType, inferredReturns, observedReturns: null, declarationKind: "Iteration contract" });
4084
4675
  this.declareBinding("self", false, { kind: "class", name: statement.name }, block.span, true);
4085
4676
  this.analyzeStatements(block.body);
4086
- this.iterateContractDepths.pop();
4087
4677
  this.returnContexts.pop();
4088
4678
  this.asynchronousFunctions.pop();
4089
4679
  this.currentClass = previousClass;
@@ -4094,37 +4684,80 @@ export class Analyzer {
4094
4684
  this.flowFrameDepth -= 1;
4095
4685
  this.exitScope();
4096
4686
  const answered = this.inferCollectedFunctionResult(inferredReturns, !this.blockAlwaysReturns(block.body));
4097
- const source = this.validatedIterationSource(statement, block, answered, baseName);
4098
- this.inferredFunctionResultTypes.set(this.iterationResultKey(block), source);
4687
+ const validated = this.validatedIterationSource(statement, block, answered, baseName, awaits);
4688
+ // D90 R12: `@iterate:` is the class's other inferred public contract. A
4689
+ // consumer writing `for item in box` reads the element straight out of
4690
+ // this block, so an element the compiler makes no promise about crosses
4691
+ // the boundary exactly as a method result does. The block has no
4692
+ // annotation to refuse and no `private` spelling, so the class's own
4693
+ // reachability is the whole question.
4694
+ if (typeContainsAnyOutput(validated.source)) {
4695
+ this.exportPositionCandidates.push({ className: statement.name, member: "@iterate", span: block.span });
4696
+ }
4697
+ // The stored result keeps the optional wrapper for the asynchronous form
4698
+ // so the convergence seed round-trips carrying the form (see
4699
+ // seededIterationInfo).
4700
+ this.inferredFunctionResultTypes.set(this.iterationResultKey(block), validated.form === "async" && !isInvalidType(validated.source) ? optionalOf(validated.source) : validated.source);
4701
+ if (validated.form === "async")
4702
+ this.asyncIterateBlocks.add(spanIdentity(block.keywordSpan));
4099
4703
  const info = this.classes.get(statement.name);
4100
- if (info)
4101
- this.classes.set(statement.name, { ...info, iterate: source });
4704
+ if (info) {
4705
+ // Drop the other form's field: an earlier pass may have seeded it before
4706
+ // this pass's answer settled which form the block is.
4707
+ const { iterate: _sync, iterateAsync: _async, ...rest } = info;
4708
+ this.classes.set(statement.name, validated.form === "async"
4709
+ ? { ...rest, iterateAsync: validated.source }
4710
+ : { ...rest, iterate: validated.source });
4711
+ }
4102
4712
  }
4103
4713
  /**
4104
- * The four collections are the whole answer space: the block says "iterating
4105
- * me is iterating this", and the language already fixed what iterating a
4106
- * List, Set, Map, or Record means. Anything else would be a second iteration
4107
- * semantics, which is the thing charter section 19 keeps out.
4714
+ * The answer space is the four collections plus `T?` (D90 R18): the
4715
+ * synchronous form says "iterating me is iterating this", and the language
4716
+ * already fixed what iterating a List, Set, Map, or Record means; the
4717
+ * asynchronous pull form answers one element per pull, null for exhaustion.
4718
+ * Anything else would be a second iteration semantics, which is the thing
4719
+ * charter section 19 keeps out.
4108
4720
  */
4109
- validatedIterationSource(statement, block, answered, baseName) {
4721
+ validatedIterationSource(statement, block, answered, baseName, awaits) {
4110
4722
  if (isInvalidType(answered) || containsInferredResultPlaceholder(answered))
4111
- return invalidType;
4723
+ return { form: "sync", source: invalidType };
4112
4724
  const expanded = this.expandAliases(answered);
4113
- if (expanded.kind !== "list" && expanded.kind !== "set" && expanded.kind !== "map" && expanded.kind !== "record") {
4114
- this.diagnostics.push(diagnostic("VEL4038", `'@iterate' says which collection iterating '${statement.name}' means, so it returns a List, Set, Map, or Record — those are the shapes the language already knows how to iterate; this block returns ${describeType(answered)}`, block.keywordSpan));
4115
- return invalidType;
4116
- }
4117
4725
  // The override rule every other member already carries (a getter or method
4118
4726
  // override keeps the base result). `@iterate:` replaces rather than chains,
4119
4727
  // but the answer still has to be the one a base-typed binding was promised:
4120
4728
  // `for item in bag` inside a function taking the base would otherwise walk
4121
- // a different element type at runtime.
4729
+ // a different element type — or a different form — at runtime.
4122
4730
  const inherited = baseName ? this.inheritedIterationSource(baseName) : null;
4731
+ const inheritedAsync = baseName ? this.inheritedAsyncIterationSource(baseName) : null;
4732
+ if (expanded.kind === "optional") {
4733
+ const element = expanded.inner;
4734
+ if (inherited && !isInvalidType(inherited)) {
4735
+ this.diagnostics.push(diagnostic("VEL4038", `'@iterate' override in '${statement.name}' must keep the base form; '${baseName}' answers ${describeType(inherited)} to the plain 'for', and this block answers ${describeType(answered)} — the asynchronous pull form — so a base-typed binding would stream where it was promised a collection`, block.keywordSpan));
4736
+ return { form: "sync", source: inherited };
4737
+ }
4738
+ if (inheritedAsync && !isInvalidType(inheritedAsync) && !sameType(this.expandAliases(element), this.expandAliases(inheritedAsync))) {
4739
+ this.diagnostics.push(diagnostic("VEL4038", `'@iterate' override in '${statement.name}' must keep the base answer ${describeType(inheritedAsync)}?; '${baseName}' already promised every caller that pulling one of these yields ${describeType(inheritedAsync)}, and a derived value is still one of those`, block.keywordSpan));
4740
+ return { form: "async", source: inheritedAsync };
4741
+ }
4742
+ return { form: "async", source: element };
4743
+ }
4744
+ if (expanded.kind !== "list" && expanded.kind !== "set" && expanded.kind !== "map" && expanded.kind !== "record") {
4745
+ this.diagnostics.push(diagnostic("VEL4038", `'@iterate' says what iterating '${statement.name}' means: the synchronous form returns a List, Set, Map, or Record — the shapes the language already knows how to iterate — and the asynchronous pull form answers 'T?', one element per pull with null as exhaustion; this block returns ${describeType(answered)}`, block.keywordSpan));
4746
+ return { form: "sync", source: invalidType };
4747
+ }
4748
+ if (awaits) {
4749
+ this.diagnostics.push(diagnostic("VEL4038", `'@iterate' in '${statement.name}' awaits but answers ${describeType(answered)}; the synchronous form is read whole by the plain consumers, so await the work before construction and hold the finished collection — or answer 'T?' to be the asynchronous pull form 'async for' drives once per element`, block.keywordSpan));
4750
+ return { form: "sync", source: invalidType };
4751
+ }
4752
+ if (inheritedAsync && !isInvalidType(inheritedAsync)) {
4753
+ this.diagnostics.push(diagnostic("VEL4038", `'@iterate' override in '${statement.name}' must keep the base form; '${baseName}' answers ${describeType(inheritedAsync)}? — the asynchronous pull form — and this block answers ${describeType(answered)}, so a base-typed binding would read a collection where it was promised a stream`, block.keywordSpan));
4754
+ return { form: "async", source: inheritedAsync };
4755
+ }
4123
4756
  if (inherited && !isInvalidType(inherited) && !sameType(expanded, this.expandAliases(inherited))) {
4124
4757
  this.diagnostics.push(diagnostic("VEL4038", `'@iterate' override in '${statement.name}' must keep the base answer ${describeType(inherited)}; '${baseName}' already promised every caller that iterating one of these walks ${describeType(inherited)}, and a derived value is still one of those`, block.keywordSpan));
4125
- return inherited;
4758
+ return { form: "sync", source: inherited };
4126
4759
  }
4127
- return expanded;
4760
+ return { form: "sync", source: expanded };
4128
4761
  }
4129
4762
  /** The `@iterate:` answer a class inherits, most derived ancestor first. */
4130
4763
  inheritedIterationSource(className) {
@@ -4139,6 +4772,30 @@ export class Analyzer {
4139
4772
  }
4140
4773
  return null;
4141
4774
  }
4775
+ /** D90 R18: the asynchronous `@iterate:` element a class inherits, most derived ancestor first. */
4776
+ inheritedAsyncIterationSource(className) {
4777
+ let current = className;
4778
+ const visited = new Set();
4779
+ while (current && !visited.has(current)) {
4780
+ visited.add(current);
4781
+ const info = this.classes.get(current);
4782
+ if (info?.iterateAsync)
4783
+ return info.iterateAsync;
4784
+ current = info?.base ?? null;
4785
+ }
4786
+ return null;
4787
+ }
4788
+ /**
4789
+ * D90 R18: what pulling this value under `async for` means. A class answers
4790
+ * through the asynchronous `@iterate:` form — its own, or the one it
4791
+ * inherits, mirroring the synchronous contract exactly.
4792
+ */
4793
+ asyncIterationContract(type) {
4794
+ const resolved = this.resolveNamedClasses(this.expandAliases(type));
4795
+ if (resolved.kind !== "class")
4796
+ return null;
4797
+ return this.inheritedAsyncIterationSource(resolved.identity ?? resolved.name);
4798
+ }
4142
4799
  /**
4143
4800
  * D68 rule 177: what iterating this value means. A class answers through
4144
4801
  * `@iterate:` — its own, or the one it inherits, because overriding replaces
@@ -4178,6 +4835,12 @@ export class Analyzer {
4178
4835
  }
4179
4836
  if (this.iterationContract(resolved) !== null)
4180
4837
  return "";
4838
+ // D90 R18: the refusal is symmetric with `async for` refusing the
4839
+ // synchronous form — each names the other, so the author is one message
4840
+ // away from the loop that fits the declaration.
4841
+ if (this.asyncIterationContract(resolved) !== null) {
4842
+ return "; '@iterate' on this class is the asynchronous pull form, which 'async for' drives — use 'async for', or answer a List, Set, Map, or Record to iterate here";
4843
+ }
4181
4844
  return "; declare an '@iterate:' block on the class to say which List, Set, Map, or Record iterating it means";
4182
4845
  }
4183
4846
  validateConstructorShape(statement) {
@@ -4290,12 +4953,37 @@ export class Analyzer {
4290
4953
  }
4291
4954
  return null;
4292
4955
  }
4293
- asyncPullElementType(source, sourceSpan) {
4956
+ asyncPullElementType(source, sourceSpan, statementStart) {
4294
4957
  const expanded = this.resolveNamedClasses(this.expandAliases(source));
4295
4958
  if (expanded.kind === "any")
4296
4959
  return anyType;
4297
4960
  if (isInvalidType(expanded))
4298
4961
  return invalidType;
4962
+ // D90 R18: a VelarScript class declares itself an asynchronous stream
4963
+ // through the asynchronous `@iterate:` form, exactly as it declares the
4964
+ // synchronous one — `async for` reads the declaration, never a structural
4965
+ // resemblance. The structural `next() -> Promise<T?>` pull below stays the
4966
+ // contract of the declared foreign shapes: capability handles (a reply
4967
+ // stream, a child process, a watcher) and extern classes whose own
4968
+ // contract declares the pull as a function-valued field.
4969
+ if (expanded.kind === "class" && !isExternClassIdentity(expanded.identity ?? null)) {
4970
+ const declared = this.asyncIterationContract(expanded);
4971
+ if (declared !== null) {
4972
+ if (isInvalidType(declared))
4973
+ return invalidType;
4974
+ this.asyncIterationStatements.add(statementStart);
4975
+ return declared;
4976
+ }
4977
+ const identity = expanded.identity ?? expanded.name;
4978
+ const synchronous = this.iterationContract(expanded);
4979
+ if (synchronous !== null) {
4980
+ this.typeError(`async for pulls a declared asynchronous '@iterate:'; '@iterate' on ${describeType(source)} ${isInvalidType(synchronous) ? "answers the plain 'for'" : `answers ${describeType(synchronous)} to the plain 'for'`} — declare the asynchronous form instead: a block that answers 'T?', one element per pull, null as exhaustion`, sourceSpan);
4981
+ return unknownType;
4982
+ }
4983
+ const structuralNext = this.findMethod(identity, "next")?.type ?? this.findMethod(expanded.name, "next")?.type ?? null;
4984
+ this.typeError(`async for pulls a declared asynchronous '@iterate:'; ${describeType(source)} does not declare one — a block that answers 'T?' (it may await; one element per pull, null is exhaustion)${structuralNext ? "; 'next()' is a method of the author's namespace, not the contract — move its body into the '@iterate:' block" : ""}`, sourceSpan);
4985
+ return unknownType;
4986
+ }
4299
4987
  let next = null;
4300
4988
  if (expanded.kind === "object") {
4301
4989
  next = expanded.optionalFields?.has("next") ? null : expanded.fields.get("next") ?? null;
@@ -4307,23 +4995,17 @@ export class Analyzer {
4307
4995
  ?? null;
4308
4996
  }
4309
4997
  else if (expanded.kind === "class") {
4310
- const identity = expanded.identity ?? expanded.name;
4311
- next = this.findField(identity, "next")?.type
4998
+ // An extern class: its own contract may declare the pull as a
4999
+ // function-valued field; an extern method is never captured (charter
5000
+ // section 12 trusts a checked declaration's member kinds, and only a
5001
+ // field promises a function standing on the value).
5002
+ next = this.findField(expanded.identity ?? expanded.name, "next")?.type
4312
5003
  ?? this.findField(expanded.name, "next")?.type
4313
- ?? (!identity.startsWith("js:") ? this.findMethod(identity, "next")?.type : null)
4314
- ?? (!identity.startsWith("js:") ? this.findMethod(expanded.name, "next")?.type : null)
4315
5004
  ?? null;
4316
5005
  }
4317
5006
  const callable = next ? this.expandAliases(next) : null;
4318
5007
  if (!callable || callable.kind !== "function" || callable.requiredParameters > 0 || (callable.typeParameterNames?.length ?? 0) > 0) {
4319
- // D68 rule 177 drew this line deliberately: `@iterate:` answers the
4320
- // synchronous question. An asynchronous stream is a resource — it has a
4321
- // lifetime, it fails, it needs releasing — so letting an ordinary class
4322
- // pose as one would make "does this need `using`?" undecidable.
4323
- const contractNote = this.iterationContract(source) === null
4324
- ? ""
4325
- : "; '@iterate' answers the plain 'for', not 'async for' — an async stream is a resource, so pull it from the capability handle that owns the lifetime";
4326
- this.typeError(`async for requires next() -> Promise<T?>; ${describeType(source)} does not expose that pull contract${contractNote}`, sourceSpan);
5008
+ this.typeError(`async for requires next() -> Promise<T?>; ${describeType(source)} does not expose that pull contract`, sourceSpan);
4327
5009
  return unknownType;
4328
5010
  }
4329
5011
  const result = this.expandAliases(callable.result);
@@ -4478,6 +5160,25 @@ export class Analyzer {
4478
5160
  const deletion = statement.resultAnnotationSpan;
4479
5161
  this.diagnostics.push(diagnostic("VEL4037", `${declarationKind} '${statement.name}' infers '-> null' from its body; delete the annotation, and write it only where 'extern', 'abstract', or a function type leaves no body to infer`, reference.span, deletion ? mechanicalFix(deletion, "", "Delete the inferred '-> null'") : undefined));
4480
5162
  }
5163
+ /**
5164
+ * D89 (message correction): the one report a `self` parameter earns, and the
5165
+ * deletion it names. The removed range reaches to the next parameter's start
5166
+ * (or back to the previous one's end), so the separating comma and its
5167
+ * whitespace come with it without reading the source text — the rewrite is a
5168
+ * spelling change with no judgment in it, which is what D38 §48 requires of
5169
+ * a registered fix.
5170
+ */
5171
+ reportImplicitSelfParameter(parameters, index) {
5172
+ const parameter = parameters[index];
5173
+ const next = parameters[index + 1];
5174
+ const previous = parameters[index - 1];
5175
+ const removal = next
5176
+ ? span(parameter.span.start, next.span.start)
5177
+ : previous
5178
+ ? span(previous.span.end, parameter.span.end)
5179
+ : parameter.span;
5180
+ this.diagnostics.push(diagnostic("VEL3007", "'self' is the receiver a method body already has, not a parameter; delete it from the parameter list", parameter.span, mechanicalFix(removal, "", "Delete the implicit 'self' parameter")));
5181
+ }
4481
5182
  analyzeFunctionDeclaration(statement, className, method = false, declareSelf = Boolean(className), forceAsynchronous = false, declarationKind = "accessor" in statement ? "Getter" : method ? "Method" : "Function") {
4482
5183
  const outerConstructorDepth = this.constructorDepth;
4483
5184
  if (!method && !className && !this.predeclared.has(statement)) {
@@ -4485,11 +5186,23 @@ export class Analyzer {
4485
5186
  }
4486
5187
  const candidateBinding = className === null ? this.lookup(statement.name) : null;
4487
5188
  const callableBinding = candidateBinding?.span.start === statement.span.start ? candidateBinding : null;
5189
+ // D85 rule 209: the same registration the top-level predeclaration makes,
5190
+ // for a `def` nested in a body, which nothing predeclares.
5191
+ if (callableBinding && !this.functionResultKeys.has(callableBinding)) {
5192
+ this.functionResultKeys.set(callableBinding, this.functionResultKey(statement));
5193
+ }
4488
5194
  this.checkTypeParameterDeclarations(statement.typeParameters);
4489
5195
  this.typeParameterFrames.push(this.typeParameterFrame(statement.typeParameters));
4490
5196
  this.enterScope();
4491
5197
  this.flowFrameDepth += 1;
4492
5198
  this.functionDepth += 1;
5199
+ // D31 item 23: this body is deferred, so its reads of imported bindings
5200
+ // become initialization-position reads only when something runs it during
5201
+ // module evaluation. Collect them here and let a top-level call decide.
5202
+ const deferredFrame = { reads: [], calls: [] };
5203
+ this.deferredReadFrames.push(deferredFrame);
5204
+ if (callableBinding)
5205
+ this.localFunctionFrames.set(callableBinding, deferredFrame);
4493
5206
  const previousLoopDepth = this.loopDepth;
4494
5207
  this.loopDepth = 0;
4495
5208
  const previousFinallyLoopDepths = this.finallyLoopDepths;
@@ -4533,7 +5246,19 @@ export class Analyzer {
4533
5246
  const selfType = { kind: "class", name: className };
4534
5247
  this.declareBinding("self", false, selfType, statement.span, true);
4535
5248
  }
4536
- for (const parameter of statement.parameters) {
5249
+ for (const [index, parameter] of statement.parameters.entries()) {
5250
+ // D89 (message correction): a method body already has `self`, so writing
5251
+ // it as a parameter is Python's explicit receiver. It used to earn two
5252
+ // reports — "already declared in this scope" and "reserved Core binding"
5253
+ // — neither of which named the fix. Only a declaration that really has
5254
+ // an implicit receiver takes this branch; a plain or static function's
5255
+ // `self` keeps the reserved-binding refusal, which is the truth there.
5256
+ // A rest spelling is not the receiver reflex, and its '...' sits outside
5257
+ // the parameter span, so deleting the name alone would leave a stray one.
5258
+ if (parameter.name === "self" && !parameter.rest && className !== null && declareSelf) {
5259
+ this.reportImplicitSelfParameter(statement.parameters, index);
5260
+ continue;
5261
+ }
4537
5262
  const contextualType = !parameter.type && parameter.defaultValue
4538
5263
  ? this.contextualFunctionParameterDefault(statement, parameter)
4539
5264
  : null;
@@ -4556,10 +5281,29 @@ export class Analyzer {
4556
5281
  const inferred = this.inferCollectedFunctionResult(inferredReturns, !this.blockAlwaysReturns(statement.body));
4557
5282
  this.inferredFunctionResultTypes.set(resultKey, inferred);
4558
5283
  const seeded = this.inferredFunctionResultSeeds.get(resultKey) ?? inferredResultPlaceholderType;
4559
- if (this.finalizeFunctionResultInference
4560
- && (containsInferredResultPlaceholder(inferred) || isInvalidType(inferred) || !sameInferredResult(seeded, inferred))) {
4561
- this.diagnostics.push(diagnostic("VEL4025", `${declarationKind} '${statement.name}' result inference did not converge; add an explicit result annotation to this recursive contract`, statement.signatureSpan));
5284
+ if (returnContext.unsettledResult === true) {
5285
+ this.reportedResultHoles.add(resultKey);
4562
5286
  }
5287
+ else if (this.finalizeFunctionResultInference
5288
+ && (containsInferredResultPlaceholder(inferred) || isInvalidType(inferred) || !sameInferredResult(seeded, inferred))) {
5289
+ const report = diagnostic("VEL4025", `${declarationKind} '${statement.name}' result inference did not converge; add an explicit result annotation to this recursive contract`, statement.signatureSpan);
5290
+ this.diagnostics.push(report);
5291
+ // D85 rule 209: a callee whose hole is reported after this caller is
5292
+ // analyzed is a hole nobody can know about yet, so the report waits
5293
+ // for the whole module before it is kept or deleted as the second
5294
+ // half of one mistake.
5295
+ const causes = returnContext.resultHoleCauses;
5296
+ if (causes && causes.size > 0)
5297
+ this.deferredConvergenceReports.push({ report, resultKey, causes });
5298
+ }
5299
+ // D90 R12: an omitted result annotation publishes whatever the body
5300
+ // inferred, so an exported `def` leaks `any` exactly as an exported
5301
+ // `const` does. Deliberately not gated on finalizeFunctionResultInference:
5302
+ // a probe pass is discarded whole — the driver keeps the first pass's
5303
+ // diagnostics only when nothing was left to converge — which is why the
5304
+ // VEL4006 below is ungated too.
5305
+ if (typeContainsAnyOutput(inferred))
5306
+ this.recordExportedAny(statement, className, statement.signatureSpan);
4563
5307
  this.updateInferredCallableResult(statement, className, callableBinding, inferred, asynchronous);
4564
5308
  }
4565
5309
  else {
@@ -4576,6 +5320,7 @@ export class Analyzer {
4576
5320
  this.superMemberContext = previousSuperMemberContext;
4577
5321
  this.loopDepth = previousLoopDepth;
4578
5322
  this.finallyLoopDepths = previousFinallyLoopDepths;
5323
+ this.deferredReadFrames.pop();
4579
5324
  this.functionDepth -= 1;
4580
5325
  this.flowFrameDepth -= 1;
4581
5326
  this.exitScope();
@@ -4655,7 +5400,7 @@ export class Analyzer {
4655
5400
  if (statement.target.kind === "IdentifierExpression") {
4656
5401
  const binding = this.lookup(statement.target.name);
4657
5402
  if (!binding) {
4658
- this.diagnostics.push(diagnostic("VEL3001", `Unknown name '${statement.target.name}'`, statement.target.span));
5403
+ this.reportUnresolvedName(statement.target.name, statement.target.span);
4659
5404
  return;
4660
5405
  }
4661
5406
  this.checkShadowedRead(statement.target.name, statement.target.span);
@@ -4829,6 +5574,8 @@ export class Analyzer {
4829
5574
  if (targetBinding?.mutable) {
4830
5575
  const storageBinding = targetBinding.storageBinding ?? targetBinding;
4831
5576
  const rebound = storageBinding.declaredType.kind !== "unknown" ? storageBinding.declaredType : valueType;
5577
+ this.recordFlowFactOrigin(storageBinding);
5578
+ this.recordFlowFactOrigin(targetBinding);
4832
5579
  storageBinding.storageType = rebound;
4833
5580
  if (storageBinding.narrowingFrame === null)
4834
5581
  storageBinding.type = rebound;
@@ -5020,9 +5767,7 @@ export class Analyzer {
5020
5767
  return unknownType;
5021
5768
  }
5022
5769
  }
5023
- const guidance = this.guidanceForGlobal(expression.name);
5024
- const nearest = guidance ? null : this.nearestVisibleBindingName(expression.name);
5025
- this.diagnostics.push(diagnostic(guidance ? "VEL3008" : "VEL3001", guidance ?? `Unknown name '${expression.name}'${nearest ? `; did you mean '${nearest}'?` : ""}`, expression.span));
5770
+ this.reportUnresolvedName(expression.name, expression.span);
5026
5771
  return unknownType;
5027
5772
  }
5028
5773
  if (lexical) {
@@ -5160,6 +5905,20 @@ export class Analyzer {
5160
5905
  if (objectContext?.kind === "named" && expectedFields?.has(property.name)) {
5161
5906
  this.semanticObjectPropertyOwners.set(`${property.span.start}:${property.name}`, objectContext);
5162
5907
  }
5908
+ // D90 R11: a literal written at a type-annotated position is
5909
+ // closed. Every one of its keys is in front of the compiler here,
5910
+ // so an unrecognised one is a misspelling rather than a value that
5911
+ // happens to be wider — which is why the openness a non-literal
5912
+ // keeps is untouched. Only written keys are checked, so a spread's
5913
+ // surplus fields stay legal and this sits outside the
5914
+ // missing-field guard below rather than inside it. A `Record<T>`
5915
+ // context declares every string key, and leaves `expectedFields`
5916
+ // null, so no key of one is ever unrecognised.
5917
+ if (objectContext && expectedFields && !expectedFields.has(property.name)) {
5918
+ const nearest = uniqueNearestName(property.name, expectedFields.keys());
5919
+ const owner = objectContext.kind === "named" ? `Type '${objectContext.name}'` : "Object";
5920
+ this.typeError(`${owner} has no field '${property.name}'${nearest ? `; did you mean '${nearest}'?` : ""}`, property.span);
5921
+ }
5163
5922
  const expected = expectedFields?.get(property.name) ?? expectedRecordValue ?? unknownType;
5164
5923
  const actual = this.inferExpression(property.value, expected.kind === "optional" ? expected.inner : expected);
5165
5924
  fields.set(property.name, expected.kind === "unknown" ? this.widenAggregateSingleton(actual) : actual);
@@ -5235,15 +5994,12 @@ export class Analyzer {
5235
5994
  const invalidExtensionAwait = this.functionDepth === 0 && this.invalidExtensionAwaitContext();
5236
5995
  if (this.parameterDefaultDepth === 0 && this.constructorDepth === 0 && (invalidFunctionAwait || invalidExtensionAwait)) {
5237
5996
  this.diagnostics.push(diagnostic("VEL4007",
5238
- // D68 rule 177: a contract block takes no `async`, so the generic
5239
- // advice would name a fix that cannot be written. All eight
5240
- // consumers read `@iterate:` synchronously `for item in bag`
5241
- // is a plain loop — so the awaiting work belongs on the way in.
5242
- this.iterateContractDepths.at(-1) === this.functionDepth
5243
- ? "'await' cannot be used in an '@iterate' block; iterating is a synchronous question — await the work before construction and hold the finished collection"
5244
- : invalidExtensionAwait
5245
- ? this.invalidExtensionAwaitMessage() ?? "'await' is not valid in this synchronous extension context"
5246
- : "'await' can only be used in an async function or at module scope", expression.span));
5997
+ // D90 R18: an `@iterate:` block that awaits is the asynchronous
5998
+ // pull form, so awaiting inside one is never refused here — the
5999
+ // form's own validation owns the answer-shape question.
6000
+ invalidExtensionAwait
6001
+ ? this.invalidExtensionAwaitMessage() ?? "'await' is not valid in this synchronous extension context"
6002
+ : "'await' can only be used in an async function or at module scope", expression.span));
5247
6003
  }
5248
6004
  const awaited = this.expandAliases(operand);
5249
6005
  if (isInvalidType(awaited))
@@ -5256,11 +6012,12 @@ export class Analyzer {
5256
6012
  }
5257
6013
  return result;
5258
6014
  }
5259
- // ASY-U2: awaiting `any` adopts a foreign thenable its hooks run
5260
- // here and a raw undefined result skips null normalization — so the
5261
- // unchecked domain is rejected exactly like `unknown`.
5262
- this.typeError(awaited.kind === "any"
5263
- ? "Cannot await any; validate the value into a checked Promise first — an unchecked thenable runs foreign hooks and can leak raw undefined"
6015
+ // ASY-U2 + D90 R17: awaiting an unchecked boundary value adopts a
6016
+ // foreign thenable — its hooks run here and a raw undefined result
6017
+ // skips null normalization so `any` and `unknown` share one
6018
+ // refusal, and it teaches the way in: a declared contract.
6019
+ this.typeError(awaited.kind === "any" || awaited.kind === "unknown"
6020
+ ? `Cannot await ${describeType(operand)}; an unchecked thenable runs foreign hooks and can leak raw undefined — declare the source in an extern contract so the result is a checked Promise, or validate the resolved data at the edge with 'Type.parse'`
5264
6021
  : `Cannot await ${describeType(operand)}`, expression.span);
5265
6022
  return unknownType;
5266
6023
  }
@@ -5415,6 +6172,7 @@ export class Analyzer {
5415
6172
  case "ArrowFunctionExpression":
5416
6173
  return this.inferArrow(expression, contextualType);
5417
6174
  case "CallExpression": {
6175
+ this.recordDeferredCallEdge(expression.callee, expression.span);
5418
6176
  const result = this.inferCall(expression.callee, expression.arguments, expression.argumentNames, expression.span, contextualType, expression.optional);
5419
6177
  if (this.expandAliases(result).kind === "null")
5420
6178
  this.normalizedNullResults.add(spanIdentity(expression.span));
@@ -5482,7 +6240,9 @@ export class Analyzer {
5482
6240
  return unknownType;
5483
6241
  }
5484
6242
  if (object.kind !== "any") {
5485
- this.typeError(`Cannot index ${describeType(object)}`, expression.span);
6243
+ // D90 R17: an unknown is a boundary value, so the refusal teaches
6244
+ // the validation ritual instead of restating the kind.
6245
+ this.typeError(`Cannot index ${describeType(object)}${object.kind === "unknown" && !isInvalidType(object) ? this.boundaryValidationGuidance(expression.object, null) : ""}`, expression.span);
5486
6246
  }
5487
6247
  return object.kind === "any" ? anyType : unknownType;
5488
6248
  }
@@ -5577,10 +6337,19 @@ export class Analyzer {
5577
6337
  this.collectionMemberships.set(spanIdentity(operationSpan), container.kind);
5578
6338
  }
5579
6339
  if (container.kind === "list" || container.kind === "set") {
5580
- this.requireMembershipIntersection(left, this.readonlyDataViewOf(container.element), leftExpression.span, operator);
6340
+ // COL-I3 second half: `in` is the thirteenth membership probe and the
6341
+ // one that does not route through `checkProbeArgument`, so it carries
6342
+ // the fresh-literal rejection itself — against the probe only, never
6343
+ // the container, because the fresh List in `x in [1, 2, 3]` is the
6344
+ // domain being searched rather than the question being asked.
6345
+ if (!this.requireMembershipIntersection(left, this.readonlyDataViewOf(container.element), leftExpression.span, operator)) {
6346
+ this.rejectFreshCollectionProbe(leftExpression, operator, "element");
6347
+ }
5581
6348
  }
5582
6349
  else if (container.kind === "map") {
5583
- this.requireMembershipIntersection(left, this.readonlyDataViewOf(container.key), leftExpression.span, operator);
6350
+ if (!this.requireMembershipIntersection(left, this.readonlyDataViewOf(container.key), leftExpression.span, operator)) {
6351
+ this.rejectFreshCollectionProbe(leftExpression, operator, "key");
6352
+ }
5584
6353
  }
5585
6354
  else if (container.kind === "record") {
5586
6355
  this.requireMembershipIntersection(left, stringType, leftExpression.span, operator);
@@ -5612,10 +6381,63 @@ export class Analyzer {
5612
6381
  this.typeError(`String concatenation requires two strings; use an f-string or str(value), received ${describeType(left)} and ${describeType(right)}`, operationSpan);
5613
6382
  return stringType;
5614
6383
  }
6384
+ if (operator === "%")
6385
+ this.adviseNegativeLiteralModulo(leftExpression, rightExpression, operationSpan);
5615
6386
  this.requireAssignable(left, numberType, leftExpression.span);
5616
6387
  this.requireAssignable(right, numberType, rightExpression.span);
5617
6388
  return numberType;
5618
6389
  }
6390
+ /**
6391
+ * D89 A3: `%` follows JavaScript and keeps the dividend's sign, so `-7 % 3`
6392
+ * is `-1` where Python answers `2`. Nothing here reports an error — both
6393
+ * languages accept the spelling, they just disagree about the result.
6394
+ *
6395
+ * Only a literal negative dividend triggers. A variable's sign is not
6396
+ * knowable, and advising every `%` whose left side might go negative would
6397
+ * be the noise the tier exists to avoid. The shape matched is a unary minus
6398
+ * wrapping a numeric literal, because that is what `-7` parses as; there is
6399
+ * no negative-valued literal for a value test to find.
6400
+ *
6401
+ * The admission bar is "Vel accepts the spelling as a different meaning", so
6402
+ * every shape whose two answers are the same is silent rather than advised:
6403
+ * a remainder of zero (`-6 % 3`) agrees, `% 0` answers NaN here and raises
6404
+ * in Python so there is no Python answer to name, and a non-finite dividend
6405
+ * answers NaN on both sides. A message that states a disagreement and then
6406
+ * prints the same number twice is a new defect, not a weaker advisory.
6407
+ */
6408
+ adviseNegativeLiteralModulo(leftExpression, rightExpression, operationSpan) {
6409
+ if (leftExpression.kind !== "UnaryExpression" || leftExpression.operator !== "-")
6410
+ return;
6411
+ const dividend = leftExpression.operand;
6412
+ if (dividend.kind !== "LiteralExpression" || typeof dividend.value !== "number")
6413
+ return;
6414
+ const divisor = rightExpression.kind === "LiteralExpression" && typeof rightExpression.value === "number"
6415
+ ? rightExpression
6416
+ : null;
6417
+ if (divisor !== null) {
6418
+ const divisorValue = Number(divisor.value);
6419
+ if (divisorValue === 0)
6420
+ return;
6421
+ // `-0` renders as `0`, so a zero remainder would print one number on
6422
+ // both sides of a sentence claiming they differ.
6423
+ const remainder = -Number(dividend.value) % divisorValue;
6424
+ if (!Number.isFinite(remainder) || remainder === 0)
6425
+ return;
6426
+ // A literal divisor is always positive — a negative one parses as a
6427
+ // unary minus, not a literal — so Python's answer, which takes the
6428
+ // divisor's sign, is this remainder lifted by one divisor.
6429
+ const python = remainder + divisorValue;
6430
+ // The rewrite the message advertises is its own remedy, so quoting an
6431
+ // answer that rewrite does not produce would be false. The two part ways
6432
+ // only when the lift rounds back onto the divisor; the general sentence
6433
+ // below covers that without naming a number.
6434
+ if ((remainder + divisorValue) % divisorValue === python) {
6435
+ this.advise("A3", `VelarScript's '%' follows JavaScript and keeps the dividend's sign, so '-${dividend.raw} % ${divisor.raw}' is ${remainder} where Python answers ${python}; write '((a % b) + b) % b' for the Python answer`, operationSpan);
6436
+ return;
6437
+ }
6438
+ }
6439
+ this.advise("A3", "VelarScript's '%' follows JavaScript and keeps the dividend's sign, so a negative dividend leaves a remainder that is negative or zero, where Python's takes the divisor's sign; write '((a % b) + b) % b' for the Python answer", operationSpan);
6440
+ }
5619
6441
  // D42 item 64: `==`/`!=` require the operand types to intersect. Strict
5620
6442
  // equality between two types that no single value inhabits is constant, so
5621
6443
  // the tightening converts a silent logic bug into a compile error. Runtime
@@ -5681,12 +6503,36 @@ export class Analyzer {
5681
6503
  // the same enum/string boundary as D42 item 64.
5682
6504
  requireMembershipIntersection(probe, domain, span, operation) {
5683
6505
  if (isInvalidType(probe) || isInvalidType(domain))
5684
- return;
6506
+ return false;
5685
6507
  if (this.equalityTypesIntersect(probe, domain))
5686
- return;
6508
+ return false;
5687
6509
  this.typeError(this.typesIntersect(probe, domain, false)
5688
6510
  ? `${describeType(probe)} can match ${describeType(domain)} only as an enum member against a raw string, and the enum and string domains never meet in '${operation}'${this.equalityGuidance(probe, domain)}`
5689
6511
  : `${describeType(probe)} and ${describeType(domain)} have no values in common, so '${operation}' can never match${this.equalityGuidance(probe, domain)}`, span);
6512
+ return true;
6513
+ }
6514
+ // COL-I3 second half: the same ruling that rejects a freshly built literal
6515
+ // as an `==` operand governs the membership vocabulary, because a membership
6516
+ // test asks the `==` question one element at a time. A literal written
6517
+ // inside the probe is a new object no element can be identical to, so the
6518
+ // answer is provable from the literal alone.
6519
+ //
6520
+ // Only the probe side is closed, deliberately. The container side is an
6521
+ // ordinary spelling — `x in [1, 2, 3]` builds the fresh List as the domain,
6522
+ // not as the question — and `Set.add`, `Set<Record>` and `Map<Record, V>`
6523
+ // are left alone for the same reason: an identity-keyed container of records
6524
+ // is a legitimate program (adding the same object twice, holding a record as
6525
+ // an identity token), so a diagnostic there would refuse correct code. A
6526
+ // false positive on a correct program is worse than silence; the probe is
6527
+ // the one position where the always-false answer is provable.
6528
+ rejectFreshCollectionProbe(probe, operation, probes) {
6529
+ const fresh = this.freshCollectionOperand(probe);
6530
+ if (!fresh)
6531
+ return false;
6532
+ this.typeError(`A ${fresh.description} built inside the probe is a new object, and '${operation}' compares ${probes} identity, so it can never match; ${probes === "key"
6533
+ ? "hold the key in a binding and probe with that binding, or compare contents with equals(a, b)"
6534
+ : "compare contents with equals(a, b) — 'values.some(item => equals(item, probe))' asks the same question one element at a time"}`, fresh.span);
6535
+ return true;
5690
6536
  }
5691
6537
  // ENM-I1: `is` / `is not` between statically disjoint enum domains is the
5692
6538
  // last equality surface that could launder one enum's member into another
@@ -5742,6 +6588,79 @@ export class Analyzer {
5742
6588
  return;
5743
6589
  this.rejectDisjointEnumTest(probe, target, "is", argument.span);
5744
6590
  }
6591
+ /**
6592
+ * D59 rule 141 settled that `toBe` *is* `==` ("toBe 必须用语言自己的 `==`")
6593
+ * and rule 141.1 settled that `toContain` *is* `values.has(item)`. The
6594
+ * runtime half of both landed; the compile-time half did not travel with
6595
+ * them, so `expect([1]).toBe([1])` compiled and failed at run time with
6596
+ * both operands rendering byte-identically, while `[1] == [1]` is refused
6597
+ * where it is written. This runs the operator's own two gates on the
6598
+ * matcher: D42 item 64's intersection requirement, and COL-I3's rejection
6599
+ * of a freshly built literal in an identity comparison.
6600
+ *
6601
+ * `toBe` and `toEqual` deliberately part company on the fresh-literal gate.
6602
+ * `toBe` asks the `==` question, where a new object can never be identical
6603
+ * to anything, so the literal proves the answer. `toEqual` asks the
6604
+ * `equals(a, b)` question, where a fresh literal is the normal and correct
6605
+ * spelling of the expected value — rejecting it there would refuse the very
6606
+ * repair the `toBe` message teaches. The intersection gate has no such
6607
+ * split: two types with no values in common never deeply equal either.
6608
+ *
6609
+ * `toHaveLength` and `toMatch` are left alone. Neither takes a comparand:
6610
+ * `toHaveLength` takes a count, and `toMatch` takes a regular-expression
6611
+ * pattern whose relation to the subject is matching, not equality.
6612
+ */
6613
+ checkTestMatcherComparand(calleeExpression, arguments_) {
6614
+ if (calleeExpression.kind !== "MemberExpression" || arguments_.length !== 1)
6615
+ return;
6616
+ const matcher = calleeExpression.property;
6617
+ if (matcher !== "toBe" && matcher !== "toEqual" && matcher !== "toContain")
6618
+ return;
6619
+ const receiver = calleeExpression.object;
6620
+ if (receiver.kind !== "CallExpression")
6621
+ return;
6622
+ const operand = this.testExpectOperands.get(spanIdentity(receiver.span));
6623
+ if (operand === undefined)
6624
+ return;
6625
+ const argument = arguments_[0];
6626
+ if (argument.kind === "SpreadExpression")
6627
+ return;
6628
+ const probe = this.inferredExpressionTypes.get(spanIdentity(argument.span));
6629
+ if (!probe)
6630
+ return;
6631
+ // `==` leaves through `inferBinary`'s invalid-type exit before either of
6632
+ // these gates runs, so the matcher that inherits the gates leaves there
6633
+ // too: an operand the compiler already refused has been named once, and
6634
+ // the always-false reading of a program that does not yet type-check is
6635
+ // not a second mistake to report.
6636
+ if (isInvalidType(operand) || isInvalidType(probe))
6637
+ return;
6638
+ if (matcher === "toContain") {
6639
+ // The membership vocabulary's own pair (ENM-I3 and COL-I3's second
6640
+ // half), asked one element at a time. Only a List receiver compares
6641
+ // element identity; text containment is code-point containment, and a
6642
+ // dynamic receiver proves nothing about which of the two it will be.
6643
+ if (operand.kind !== "list")
6644
+ return;
6645
+ const contained = this.readonlyDataViewOf(operand.element);
6646
+ if (!this.requireMembershipIntersection(probe, contained, argument.span, matcher)) {
6647
+ this.rejectFreshCollectionProbe(argument, matcher, "element");
6648
+ }
6649
+ return;
6650
+ }
6651
+ if (this.requireMembershipIntersection(probe, operand, argument.span, matcher))
6652
+ return;
6653
+ if (matcher !== "toBe")
6654
+ return;
6655
+ // Either side settles it: `expect([1]).toBe(list)` is as constant as
6656
+ // `expect(list).toBe([1])`, exactly as `==` treats its two operands.
6657
+ const actualExpression = receiver.arguments[0];
6658
+ const fresh = this.freshCollectionOperand(argument)
6659
+ ?? (actualExpression && actualExpression.kind !== "SpreadExpression" ? this.freshCollectionOperand(actualExpression) : null);
6660
+ if (!fresh)
6661
+ return;
6662
+ this.typeError(`A ${fresh.description} built inside the expectation is a new object, and 'toBe' compares collection identity, so it can never match; compare contents with 'toEqual(expected)'`, fresh.span);
6663
+ }
5745
6664
  enumTargetOfValidatorObject(object) {
5746
6665
  if (object.kind !== "IdentifierExpression")
5747
6666
  return null;
@@ -6131,6 +7050,13 @@ export class Analyzer {
6131
7050
  this.enterScope();
6132
7051
  this.flowFrameDepth += 1;
6133
7052
  this.functionDepth += 1;
7053
+ // D31 item 23: an arrow bound to a module-local name is the other deferred
7054
+ // body a top-level call can run, and the binding does not exist until the
7055
+ // declaration finishes, so the frame is filed by the arrow's own span and
7056
+ // the declaration claims it afterwards.
7057
+ const deferredFrame = { reads: [], calls: [] };
7058
+ this.deferredReadFrames.push(deferredFrame);
7059
+ this.arrowDeferredFrames.set(spanIdentity(expression.span), deferredFrame);
6134
7060
  const previousFinallyLoopDepths = this.finallyLoopDepths;
6135
7061
  this.finallyLoopDepths = [];
6136
7062
  this.asynchronousFunctions.push(expression.asynchronous);
@@ -6179,12 +7105,21 @@ export class Analyzer {
6179
7105
  this.arrowOwnedCaptures.set(spanIdentity(expression.span), captured);
6180
7106
  this.parameterDefaultDepth = outerParameterDefaultDepth;
6181
7107
  this.constructorDepth = outerConstructorDepth;
6182
- const checkedBodyResult = expected
7108
+ let checkedBodyResult = expected
6183
7109
  && expandedExpectedResult.kind !== "unknown"
6184
7110
  && expandedExpectedResult.kind !== "any"
6185
7111
  && this.contextuallyAssignable(bodyResult, contextualResult, expression.body.span)
6186
7112
  ? contextualResult
6187
7113
  : bodyResult;
7114
+ // D85 rule 207: with no contextual result the arrow's body is the only
7115
+ // thing that says what it returns, so an empty collection written there
7116
+ // has nothing settling it — the same position a body-inferred `return`
7117
+ // occupies, reported the same way. Rule 209: once reported, the arrow's
7118
+ // result is invalid rather than a `List<unknown>` a caller reports again.
7119
+ if (expandedExpectedResult.kind === "unknown"
7120
+ && this.requireSettledCollectionElement(expression.body, checkedBodyResult, false)) {
7121
+ checkedBodyResult = invalidType;
7122
+ }
6188
7123
  const result = expression.asynchronous
6189
7124
  ? { kind: "promise", value: this.resolvedAsyncResult(checkedBodyResult) }
6190
7125
  : checkedBodyResult;
@@ -6200,6 +7135,7 @@ export class Analyzer {
6200
7135
  }
6201
7136
  this.asynchronousFunctions.pop();
6202
7137
  this.finallyLoopDepths = previousFinallyLoopDepths;
7138
+ this.deferredReadFrames.pop();
6203
7139
  this.functionDepth -= 1;
6204
7140
  this.flowFrameDepth -= 1;
6205
7141
  this.exitScope();
@@ -6351,6 +7287,18 @@ export class Analyzer {
6351
7287
  }
6352
7288
  return source.fields.size === 0 && expectedMap ? expectedMap : { kind: "map", key: stringType, value };
6353
7289
  }
7290
+ // A `Record<V>` is the dynamic-key record, and `__velarCreateMap` has
7291
+ // always read it. Only the structural `object` shape was accepted here,
7292
+ // so the diagnostic below listed "a record" among the forms it takes and
7293
+ // then refused one. Keys of a record are strings by construction.
7294
+ if (source.kind === "record") {
7295
+ const value = source.readonlyView ? this.readonlyDataViewOf(source.value) : source.value;
7296
+ if (expectedMap) {
7297
+ this.requireAssignable(stringType, expectedMap.key, argument.span);
7298
+ this.requireAssignable(value, expectedMap.value, argument.span);
7299
+ }
7300
+ return { kind: "map", key: stringType, value };
7301
+ }
6354
7302
  if (source.kind === "any")
6355
7303
  return { kind: "map", key: anyType, value: anyType };
6356
7304
  this.typeError(`Map construction requires a Map, a List of [key, value] Lists, or a record, received ${describeType(source)}${this.iterationGuidance(source)}`, argument.span);
@@ -6467,8 +7415,15 @@ export class Analyzer {
6467
7415
  && arguments_[0]?.kind === "ObjectExpression" && callee.result.kind === "named") {
6468
7416
  this.recordRuntimeObjectShape(arguments_[0], callee.result);
6469
7417
  }
7418
+ const diagnosticsBeforeArguments = this.diagnostics.length;
6470
7419
  this.checkArguments(arguments_, callee.parameters, callSpan, callee.requiredParameters, callee.rest, argumentNames, callee.parameterNames);
6471
7420
  this.rejectDisjointEnumValidatorProbe(calleeExpression, arguments_);
7421
+ // One mistake, one diagnostic: the matcher gate speaks only when the
7422
+ // argument itself checked out, because an unassignable comparand has
7423
+ // already been named in the words the author needs.
7424
+ if (this.diagnostics.length === diagnosticsBeforeArguments) {
7425
+ this.checkTestMatcherComparand(calleeExpression, arguments_);
7426
+ }
6472
7427
  this.reportPromiseCarrierHazard(callee.result, callSpan);
6473
7428
  if (callee.result.kind === "optional")
6474
7429
  this.optionalCalls.add(spanIdentity(callSpan));
@@ -6507,7 +7462,10 @@ export class Analyzer {
6507
7462
  for (const argument of arguments_) {
6508
7463
  this.inferExpression(argument);
6509
7464
  }
6510
- this.typeError("Cannot call an unknown JavaScript value without a declaration or validation", callSpan);
7465
+ // D90 R17: a call needs a declared signature `Type.parse` validates
7466
+ // data, so the way in for a callable is the extern contract.
7467
+ const receiver = calleeExpression ? this.boundaryReceiverText(calleeExpression) : null;
7468
+ this.typeError(`Cannot call an unknown JavaScript value without a declaration or validation; declare the signature — an 'extern module' contract or a contracted 'extern js' block gives ${receiver ? `'${receiver}'` : "the value"} a checked type — or validate the data it came from with 'Type.parse' first`, callSpan);
6511
7469
  }
6512
7470
  return unknownType;
6513
7471
  }
@@ -6741,8 +7699,17 @@ export class Analyzer {
6741
7699
  const value = argument.kind === "SpreadExpression" ? argument.value : argument;
6742
7700
  if (value.kind === "ArrowFunctionExpression")
6743
7701
  deferredNamedArrows.add(value);
6744
- else
6745
- this.inferExpression(value, target === null ? unknownType : intrinsic.parameters[target] ?? intrinsic.rest ?? unknownType);
7702
+ else {
7703
+ const declared = target === null ? unknownType : intrinsic.parameters[target] ?? intrinsic.rest ?? unknownType;
7704
+ // D90 R17: an accept-anything parameter is spelled `List<unknown>`
7705
+ // in the vocabulary tables, and that spelling carries no element
7706
+ // information — preanalyzing a literal against it would launder
7707
+ // `[1, 2]` into a list the handler can read no numbers from, so the
7708
+ // literal keeps its own inferred element and the handler's own
7709
+ // expected type does the checking.
7710
+ const context = declared.kind === "list" && declared.element.kind === "unknown" ? unknownType : declared;
7711
+ this.inferExpression(value, context);
7712
+ }
6746
7713
  }
6747
7714
  if (!named.valid) {
6748
7715
  for (const argument of deferredNamedArrows)
@@ -7142,7 +8109,9 @@ export class Analyzer {
7142
8109
  fields.set("toBeFalsy", { kind: "function", parameters: [], requiredParameters: 0, result: nullType });
7143
8110
  }
7144
8111
  if (matched.kind === "list" || matched.kind === "string" || dynamic) {
7145
- const contained = matched.kind === "list" ? matched.element : matched.kind === "string" ? stringType : anyType;
8112
+ // D90 R17: an accept-anything parameter position is `unknown`, the
8113
+ // top type for assignment targets; `any` stays a value kind only.
8114
+ const contained = matched.kind === "list" ? matched.element : matched.kind === "string" ? stringType : unknownType;
7146
8115
  fields.set("toContain", { kind: "function", parameterNames: ["expected"], parameters: [contained], requiredParameters: 1, result: nullType });
7147
8116
  fields.set("toHaveLength", { kind: "function", parameterNames: ["length"], parameters: [numberType], requiredParameters: 1, result: nullType });
7148
8117
  }
@@ -7432,7 +8401,7 @@ export class Analyzer {
7432
8401
  // Map/Record key of `get`) is judged by intersection with the element or
7433
8402
  // key domain — the per-element `==` question — rather than assignability,
7434
8403
  // whose enum -> string one-way exit would launder a bare-string match.
7435
- const checkProbeArgument = (domain, operation) => {
8404
+ const checkProbeArgument = (domain, operation, probes = "element") => {
7436
8405
  requireCount(1);
7437
8406
  const argument = argumentAt(0);
7438
8407
  if (!argument)
@@ -7442,7 +8411,12 @@ export class Analyzer {
7442
8411
  return;
7443
8412
  }
7444
8413
  const probe = namedPreanalyzed ? this.inferredExpressionType(argument) : this.inferExpression(argument, domain);
7445
- this.requireMembershipIntersection(probe, domain, argument.span, operation);
8414
+ // The domain mismatch is the more precise answer where both apply, so
8415
+ // the fresh-literal rejection speaks only when the probe's type is right
8416
+ // and identity is the sole reason it can never match.
8417
+ if (!this.requireMembershipIntersection(probe, domain, argument.span, operation)) {
8418
+ this.rejectFreshCollectionProbe(argument, operation, probes);
8419
+ }
7446
8420
  if (!namedPreanalyzed) {
7447
8421
  for (const extra of arguments_.slice(1)) {
7448
8422
  if (!omitted(extra))
@@ -7736,7 +8710,7 @@ export class Analyzer {
7736
8710
  this.typeError("Use 'get(key) ?? fallback'; Map.get has one optional-result contract", callSpan);
7737
8711
  return optionalOf(readonlyValue);
7738
8712
  }
7739
- checkProbeArgument(comparisonKey, "Map.get");
8713
+ checkProbeArgument(comparisonKey, "Map.get", "key");
7740
8714
  return optionalOf(readonlyValue);
7741
8715
  }
7742
8716
  if (member.property === "keys") {
@@ -7756,12 +8730,12 @@ export class Analyzer {
7756
8730
  }
7757
8731
  if (member.property === "has") {
7758
8732
  this.collectionCalls.set(member.span.end, "mapHas");
7759
- checkProbeArgument(comparisonKey, "Map.has");
8733
+ checkProbeArgument(comparisonKey, "Map.has", "key");
7760
8734
  return boolType;
7761
8735
  }
7762
8736
  if (member.property === "remove") {
7763
8737
  this.collectionCalls.set(member.span.end, "mapRemove");
7764
- checkProbeArgument(comparisonKey, "Map.remove");
8738
+ checkProbeArgument(comparisonKey, "Map.remove", "key");
7765
8739
  return boolType;
7766
8740
  }
7767
8741
  if (member.property === "clear") {
@@ -7783,7 +8757,7 @@ export class Analyzer {
7783
8757
  }
7784
8758
  if (member.property === "get") {
7785
8759
  this.collectionCalls.set(member.span.end, "recordGet");
7786
- checkProbeArgument(stringType, "Record.get");
8760
+ checkProbeArgument(stringType, "Record.get", "key");
7787
8761
  return optionalOf(readonlyValue);
7788
8762
  }
7789
8763
  if (member.property === "keys") {
@@ -7803,12 +8777,12 @@ export class Analyzer {
7803
8777
  }
7804
8778
  if (member.property === "has") {
7805
8779
  this.collectionCalls.set(member.span.end, "recordHas");
7806
- checkProbeArgument(stringType, "Record.has");
8780
+ checkProbeArgument(stringType, "Record.has", "key");
7807
8781
  return boolType;
7808
8782
  }
7809
8783
  if (member.property === "remove") {
7810
8784
  this.collectionCalls.set(member.span.end, "recordRemove");
7811
- checkProbeArgument(stringType, "Record.remove");
8785
+ checkProbeArgument(stringType, "Record.remove", "key");
7812
8786
  return boolType;
7813
8787
  }
7814
8788
  if (member.property === "clear") {
@@ -8048,7 +9022,7 @@ export class Analyzer {
8048
9022
  if (isInvalidType(object))
8049
9023
  result = invalidType;
8050
9024
  else
8051
- this.typeError(`Cannot access '${property}' on unknown without validation`, memberSpan);
9025
+ this.typeError(`Cannot access '${property}' on unknown without validation${this.boundaryValidationGuidance(objectExpression, property)}`, memberSpan);
8052
9026
  }
8053
9027
  else if (object.kind === "string") {
8054
9028
  result = this.stringMember(property) ?? unknownType;
@@ -8741,6 +9715,7 @@ export class Analyzer {
8741
9715
  updateInferredCallableResult(statement, className, binding, result, asynchronous) {
8742
9716
  if (binding) {
8743
9717
  const type = this.callableWithInferredResult(binding.declaredType, result, asynchronous);
9718
+ this.recordFlowFactOrigin(binding);
8744
9719
  binding.type = type;
8745
9720
  binding.declaredType = type;
8746
9721
  binding.storageType = type;
@@ -8919,8 +9894,18 @@ export class Analyzer {
8919
9894
  this.classDisplayNames.set(type.identity, local);
8920
9895
  return type;
8921
9896
  }
9897
+ // D90 R17: an undeclared foreign value arrives as unknown — R12 refused
9898
+ // `any` at export positions, and this closes the entry. The value must be
9899
+ // validated into a concrete type (`Type.parse`) before members, calls, or
9900
+ // operators touch it; `unsafe` names the missing declaration, not a
9901
+ // license to chain through the boundary. A host-injected binding is a
9902
+ // declaration — the host answered for the name — so it still wins. The
9903
+ // boundary marker matters: a bare `unknown` is the inference seed a merge
9904
+ // absorbs, while this value is *known to be unchecked*, so `[mystery, 5]`
9905
+ // must stay `List<unknown | number>` instead of laundering into
9906
+ // `List<number>`.
8922
9907
  if (statement.unsafe)
8923
- return anyType;
9908
+ return this.importBindings.get(local) ?? boundaryUnknownType;
8924
9909
  const declarations = this.externModules.get(statement.source);
8925
9910
  if (namespace)
8926
9911
  return declarations
@@ -9278,9 +10263,10 @@ export class Analyzer {
9278
10263
  // whether the value is true, so 'false' and null take the same else path and
9279
10264
  // 'if flag:' stays the spelling for both. Any other optional has to say
9280
10265
  // which question it asks, because "holds a value" and "is true" are
9281
- // different tests. BRG-N4: `any` is rejected tooraw JavaScript
9282
- // truthiness would judge 0 and "" false, which breaks the owner's ruling
9283
- // that a condition judges only bool; validate the boundary value first.
10266
+ // different tests. BRG-N4 + D90 R17: an unchecked boundary value`any` or
10267
+ // `unknown` is rejected with one message: raw JavaScript truthiness would
10268
+ // judge 0 and "" false, which breaks the owner's ruling that a condition
10269
+ // judges only bool, so the boundary value is validated first.
9284
10270
  requireCondition(type, condition) {
9285
10271
  this.checkGetterNarrowingTest(condition);
9286
10272
  if (isInvalidType(type))
@@ -9288,8 +10274,8 @@ export class Analyzer {
9288
10274
  const expanded = this.expandAliases(type);
9289
10275
  if (expanded.kind === "bool")
9290
10276
  return;
9291
- if (expanded.kind === "any") {
9292
- this.typeError("A condition judges only bool, and an unchecked any would ride JavaScript truthiness (0 and \"\" become false); validate the value first, or compare it explicitly", condition.span);
10277
+ if (expanded.kind === "any" || expanded.kind === "unknown") {
10278
+ this.typeError(`A condition judges only bool, and an unchecked ${describeType(type)} would ride JavaScript truthiness (0 and "" become false); validate the value at the edge — 'Type.parse' — and judge the checked result, or compare it explicitly`, condition.span);
9293
10279
  return;
9294
10280
  }
9295
10281
  if (expanded.kind === "optional") {
@@ -9377,6 +10363,45 @@ export class Analyzer {
9377
10363
  }
9378
10364
  return null;
9379
10365
  }
10366
+ /**
10367
+ * D90 R17: the author's own spelling of a boundary value, for the
10368
+ * diagnostics that teach `Type.parse`. Identifier and member paths render
10369
+ * exactly, a simple call renders as `name(...)`, and anything else answers
10370
+ * null so the caller falls back to the word `value`.
10371
+ */
10372
+ boundaryReceiverText(expression) {
10373
+ if (expression.kind === "IdentifierExpression")
10374
+ return expression.name;
10375
+ if (expression.kind === "MemberExpression" && !expression.optional) {
10376
+ const owner = this.boundaryReceiverText(expression.object);
10377
+ return owner === null ? null : `${owner}.${expression.property}`;
10378
+ }
10379
+ if (expression.kind === "CallExpression") {
10380
+ const callee = this.boundaryReceiverText(expression.callee);
10381
+ return callee === null ? null : `${callee}(...)`;
10382
+ }
10383
+ return null;
10384
+ }
10385
+ /** A type name suggested from the receiver's last name segment, or 'X' when none reads naturally. */
10386
+ boundaryTypeNameSuggestion(receiver) {
10387
+ const segment = receiver?.replace(/\(\.\.\.\)$/u, "").split(".").at(-1) ?? "";
10388
+ return /^[a-zA-Z]/u.test(segment) ? segment[0].toUpperCase() + segment.slice(1) : "X";
10389
+ }
10390
+ /**
10391
+ * D90 R17: an undeclared foreign value arrives as unknown, and the way into
10392
+ * the typed world is `Type.parse` at the edge. Every refusal on an unknown
10393
+ * teaches that ritual with the author's own expression spelled into it.
10394
+ */
10395
+ boundaryValidationGuidance(expression, property) {
10396
+ const receiver = expression ? this.boundaryReceiverText(expression) : null;
10397
+ const name = this.boundaryTypeNameSuggestion(receiver);
10398
+ const spelled = receiver ?? "value";
10399
+ const declared = property === null
10400
+ ? `declare a type naming the shape you rely on — 'type ${name}:'`
10401
+ : `declare a type naming the fields you rely on — 'type ${name}:' with the '${property}' field`;
10402
+ const read = property === null ? "use 'checked' from there" : `read 'checked.${property}'`;
10403
+ return `; ${declared} — then validate first: 'const checked = ${name}.parse(${spelled})' and ${read}`;
10404
+ }
9380
10405
  // Presence guidance names the exact spelling to write whenever the condition
9381
10406
  // is a plain name or a plain member path; anything else is taught the
9382
10407
  // operator without inventing source text for it.
@@ -9434,6 +10459,17 @@ export class Analyzer {
9434
10459
  this.typeError(`Cannot assign ${actualDescription} to ${expectedDescription}; ${asyncResult}`, valueSpan);
9435
10460
  return;
9436
10461
  }
10462
+ // D90 R17: an undeclared foreign value is unknown until validated, so
10463
+ // the mismatch teaches the entry ritual instead of restating the kinds.
10464
+ if (expandedActual.kind === "unknown" && !isInvalidType(expandedActual)) {
10465
+ const named = expectedCore.kind === "named" || expectedCore.kind === "enum"
10466
+ ? `'const checked = ${describeType(expectedCore)}.parse(value)'`
10467
+ : expectedCore.kind === "string" || expectedCore.kind === "number" || expectedCore.kind === "bool"
10468
+ ? `narrow it with 'value is ${describeType(expectedCore)}', or parse a declared shape`
10469
+ : "declare a type naming the shape you rely on and call 'Type.parse' on the value";
10470
+ this.typeError(`Cannot assign ${actualDescription} to ${expectedDescription}; a boundary value stays unknown until validated at the edge — ${named}`, valueSpan);
10471
+ return;
10472
+ }
9437
10473
  // COL-U10: a value of one collection family in another family's
9438
10474
  // position gets the bridge spelling, not a bare mismatch.
9439
10475
  const bridge = this.collectionBridgeGuidance(expandedActual, expectedCore);
@@ -9979,8 +11015,12 @@ export class Analyzer {
9979
11015
  return extensionResult;
9980
11016
  switch (syntax.kind) {
9981
11017
  case "NamedTypeSyntax": {
11018
+ // D90 R17 removed the boundary that used to produce `any`, so the
11019
+ // old reason clause ("reserved for explicit unsafe JavaScript
11020
+ // boundaries") named a producer that no longer exists. The refusal
11021
+ // now teaches the same entrance every other unknown refusal teaches.
9982
11022
  if (syntax.name === "any") {
9983
- this.typeError("'any' is reserved for explicit unsafe JavaScript boundaries; use 'unknown' in VelarScript", syntax.span);
11023
+ this.typeError(`'any' is not a VelarScript type; a foreign value arrives as 'unknown', which is what you annotate${this.boundaryValidationGuidance(null, null)}`, syntax.span);
9984
11024
  return false;
9985
11025
  }
9986
11026
  if (this.invalidDeclaredTypes.has(syntax.name))
@@ -10387,6 +11427,30 @@ export class Analyzer {
10387
11427
  }
10388
11428
  }
10389
11429
  }
11430
+ /**
11431
+ * D50 rule 97.3: a retirement that leaves one surviving spelling did not
11432
+ * happen. `export {stringify} from "velar/json"` is an import spelling with
11433
+ * an export in front of it — the barrel republishes the retired bare name
11434
+ * and every downstream `import {stringify} from "./barrel.vel"` is clean
11435
+ * forever after. No mechanical fix: which reads in which other modules
11436
+ * wanted the name is not a rewrite this module can make.
11437
+ */
11438
+ reportPermanentNamespaceReExports(program) {
11439
+ for (const statement of program.body) {
11440
+ if (statement.kind !== "ReExportDeclaration")
11441
+ continue;
11442
+ const roster = permanentNamespaceImportRoster(statement.source);
11443
+ if (!roster)
11444
+ continue;
11445
+ for (const specifier of statement.specifiers) {
11446
+ if (!roster.members.has(specifier.imported))
11447
+ continue;
11448
+ this.diagnostics.push(diagnostic("VEL3008", roster.namespace === null
11449
+ ? `Use ${specifier.imported}(...) directly; a re-export cannot restore a retired import spelling, and the Core prelude needs none`
11450
+ : `Use ${roster.namespace}.${specifier.imported} directly; a re-export cannot restore a retired import spelling`, specifier.span));
11451
+ }
11452
+ }
11453
+ }
10390
11454
  /** A member name to show in the rule 106 guidance, so the fix is concrete. */
10391
11455
  firstNamespaceMember(namespace) {
10392
11456
  const binding = this.builtin(namespace);
@@ -10403,6 +11467,25 @@ export class Analyzer {
10403
11467
  recoveredTypeError(message, errorSpan, fix) {
10404
11468
  this.diagnostics.push(recoveredDiagnostic("VEL4001", message, errorSpan, fix));
10405
11469
  }
11470
+ /**
11471
+ * D89: raises a roster advisory. It cannot reach `this.diagnostics`, so it
11472
+ * cannot fail a build and cannot shift the diagnostic cursors this analyzer
11473
+ * reads as array lengths.
11474
+ *
11475
+ * One report per code and span. `reanalyzeLoopBackEdge` runs a loop body a
11476
+ * second time whenever the back edge invalidates a fact, and its diagnostic
11477
+ * answer is `deduplicateDiagnostics`, which only ever touches
11478
+ * `this.diagnostics`. Deduplicating where the advisory is raised covers that
11479
+ * pass and every other re-analysis without a second pair of cursors, which
11480
+ * is the whole reason the two channels are separate arrays.
11481
+ */
11482
+ advise(code, message, adviceSpan, fix) {
11483
+ const identity = `${code}\u0000${adviceSpan.start}\u0000${adviceSpan.end}`;
11484
+ if (this.advisedIdentities.has(identity))
11485
+ return;
11486
+ this.advisedIdentities.add(identity);
11487
+ this.advisories.push(advisory(code, message, adviceSpan, fix));
11488
+ }
10406
11489
  analyzeMatchPattern(pattern, input, bindings) {
10407
11490
  switch (pattern.kind) {
10408
11491
  case "MatchAsPattern": {
@@ -10884,11 +11967,15 @@ export class Analyzer {
10884
11967
  }
10885
11968
  builtin(name) {
10886
11969
  const type = this.extensionGlobals.get(name) ?? coreVocabularyType(name)
10887
- ?? (name === "Error" || name === "ValidationError" || name === "NarrowingError" || name === "IndexError"
11970
+ ?? (name === "Error" || name === "ValidationError" || name === "AssertionError"
11971
+ || name === "NarrowingError" || name === "IndexError"
10888
11972
  || VELAR_HOST_ERROR_NAMES.includes(name)
10889
11973
  ? { kind: "classConstructor", name }
10890
11974
  : null)
10891
- ?? (name === "Map" || name === "Set" ? anyType : null);
11975
+ // D90 R17: `Map`/`Set` as bare values are collection constructors the
11976
+ // call path special-cases; the bare binding itself carries no members,
11977
+ // so it is unknown, never a silent `any`.
11978
+ ?? (name === "Map" || name === "Set" ? unknownType : null);
10892
11979
  return type ? {
10893
11980
  mutable: false,
10894
11981
  type,
@@ -10904,19 +11991,144 @@ export class Analyzer {
10904
11991
  * constructor's own arguments. Nothing infers it from a later mutation, so a
10905
11992
  * binding left with no source is reported at the construction rather than
10906
11993
  * kept as `unknown` for a following line to fill in.
11994
+ *
11995
+ * The value written at this position is not always the construction itself.
11996
+ * A ternary arm, a list element or its spread, a record-literal field, a
11997
+ * `??` fallback, a receiver and an argument all become part of the value the
11998
+ * name holds, so each is its own settling position and each reports at its
11999
+ * own `[]`. What stops the walk is the value, not the syntax: it descends
12000
+ * only while `carriesUnsettledCollection` still sees the hole in the type
12001
+ * arriving here, so `print(Set().size)` and `const n = Set().size` stay
12002
+ * legal per rule 208 — neither of those names holds a collection — while a
12003
+ * spread whose `unknown` the merge absorbs (`["x", ...[]]`) leaves nothing
12004
+ * to report. A sibling settles nothing for its neighbour: `[["a"], []]`
12005
+ * merges through `unionOf`, so the union still carries the hole and the
12006
+ * empty `[]` reports on its own.
12007
+ *
12008
+ * Returns whether it reported, so the caller can hand the name `invalidType`
12009
+ * instead of the hole. Rule 209 requires one mistake to be reported once,
12010
+ * and `List<unknown>` reaching a later line is what produces the second,
12011
+ * contradicting report the ruling exists to delete.
10907
12012
  */
10908
12013
  requireSettledCollectionElement(initializer, declared, annotated) {
10909
12014
  if (annotated)
12015
+ return false;
12016
+ return this.reportUnsettledCollection(initializer, this.expandAliases(declared));
12017
+ }
12018
+ /**
12019
+ * D85 rule 209: where the value at this position came from, when it came
12020
+ * from a hole VEL4039 already reported. The answer is two-part because a
12021
+ * callee can be declared after its caller: `true` is a hole already on
12022
+ * record, and `causes` are the local results that make this position a hole
12023
+ * too if theirs turn out to be one.
12024
+ *
12025
+ * Only a name and a call to a local name are modelled — the two shapes an
12026
+ * author writes between an empty collection and the `return` that publishes
12027
+ * it. Anything else contributes nothing, so an unmodelled position keeps the
12028
+ * report it has today rather than losing one.
12029
+ */
12030
+ collectResultHoleSources(expression, causes) {
12031
+ if (expression.kind === "IdentifierExpression") {
12032
+ const binding = this.lookup(expression.name);
12033
+ if (!binding)
12034
+ return false;
12035
+ for (const cause of this.bindingHoleCauses.get(binding) ?? [])
12036
+ causes.add(cause);
12037
+ return this.reportedCollectionHoles.has(binding);
12038
+ }
12039
+ if (expression.kind === "CallExpression" && expression.callee.kind === "IdentifierExpression") {
12040
+ const binding = this.lookup(expression.callee.name);
12041
+ const resultKey = binding ? this.functionResultKeys.get(binding) : undefined;
12042
+ // An imported, dynamically dispatched, or method call resolves to no
12043
+ // local result. Its hole — if it has one — was reported in the module
12044
+ // that owns it, and nothing here can say so, so the call is not a cause.
12045
+ if (resultKey === undefined)
12046
+ return false;
12047
+ if (this.reportedResultHoles.has(resultKey))
12048
+ return true;
12049
+ causes.add(resultKey);
12050
+ return false;
12051
+ }
12052
+ return false;
12053
+ }
12054
+ /**
12055
+ * D85 rule 209: a name bound to a reported hole carries it, so `const a = []`
12056
+ * followed by `return a` is the same one mistake `return []` is. Only an
12057
+ * unannotated `const`/`let` of a single name carries anything: an annotation
12058
+ * settles the construction, and a destructuring pattern takes the hole apart
12059
+ * rather than passing it on.
12060
+ */
12061
+ recordBindingHoleSource(pattern, initializer, reported) {
12062
+ if (pattern.kind !== "NameBindingPattern")
12063
+ return;
12064
+ const binding = this.scopes.at(-1)?.get(pattern.name);
12065
+ if (!binding)
10910
12066
  return;
10911
- const type = this.expandAliases(declared);
10912
- if (!this.isFreshUnresolvedCollection(initializer, type))
12067
+ const causes = new Set();
12068
+ if (reported || this.collectResultHoleSources(initializer, causes)) {
12069
+ this.reportedCollectionHoles.add(binding);
12070
+ return;
12071
+ }
12072
+ if (causes.size > 0)
12073
+ this.bindingHoleCauses.set(binding, causes);
12074
+ }
12075
+ /**
12076
+ * D85 rule 209: delete the convergence report of every function whose result
12077
+ * is invalid only because a hole VEL4039 already explained reached it through
12078
+ * a local call. The set grows until it stops growing, because a chain of
12079
+ * forwarding functions is still one mistake however long it is — and a cycle
12080
+ * with no empty collection anywhere in it never enters the set, so a genuine
12081
+ * convergence failure still reports on both of its halves.
12082
+ */
12083
+ resolveDeferredConvergenceReports() {
12084
+ if (this.deferredConvergenceReports.length === 0)
10913
12085
  return;
10914
- const [spelling, holds, example] = type.kind === "list"
10915
- ? ["[]", "what the List holds", "let items: List<string> = []"]
10916
- : type.kind === "set"
10917
- ? ["Set()", "what the Set holds", "const tags: Set<string> = Set()"]
10918
- : ["Map()", "what the Map holds", "const users: Map<string, User> = Map()"];
10919
- this.diagnostics.push(diagnostic("VEL4039", `Empty '${spelling}' requires an explicit type; nothing at this position says ${holds} — write '${example}'`, initializer.span));
12086
+ const suppressed = new Set();
12087
+ for (let growing = true; growing;) {
12088
+ growing = false;
12089
+ for (const entry of this.deferredConvergenceReports) {
12090
+ if (suppressed.has(entry.report))
12091
+ continue;
12092
+ if (![...entry.causes].some((cause) => this.reportedResultHoles.has(cause)))
12093
+ continue;
12094
+ suppressed.add(entry.report);
12095
+ this.reportedResultHoles.add(entry.resultKey);
12096
+ growing = true;
12097
+ }
12098
+ }
12099
+ for (let index = this.diagnostics.length - 1; index >= 0; index -= 1) {
12100
+ const report = this.diagnostics[index];
12101
+ if (report && suppressed.has(report))
12102
+ this.diagnostics.splice(index, 1);
12103
+ }
12104
+ }
12105
+ reportUnsettledCollection(expression, type) {
12106
+ if (type !== null) {
12107
+ if (this.isFreshUnresolvedCollection(expression, type)) {
12108
+ const [spelling, holds, example] = type.kind === "list"
12109
+ ? ["[]", "what the List holds", "let items: List<string> = []"]
12110
+ : type.kind === "set"
12111
+ ? ["Set()", "what the Set holds", "const tags: Set<string> = Set()"]
12112
+ : ["Map()", "what the Map holds", "const users: Map<string, User> = Map()"];
12113
+ this.diagnostics.push(diagnostic("VEL4039", `Empty '${spelling}' requires an explicit type; nothing at this position says ${holds} — write '${example}'`, expression.span));
12114
+ return true;
12115
+ }
12116
+ if (!carriesUnsettledCollection(type))
12117
+ return false;
12118
+ }
12119
+ let reported = false;
12120
+ for (const part of settlingValuePositions(expression)) {
12121
+ // A part analyzed under a contextual type that settled it never reaches
12122
+ // here as `unknown`; one that was analyzed at all has its answer on
12123
+ // record. A part with no answer on record was never inferred as a whole
12124
+ // — `Map([[key, value]])` reads the entry's two leaves and never the
12125
+ // entry list itself — so the walk carries on through the gap rather
12126
+ // than stopping at one it did not make.
12127
+ const partType = this.inferredExpressionTypes.get(spanIdentity(part.span));
12128
+ if (this.reportUnsettledCollection(part, partType ? this.expandAliases(partType) : null))
12129
+ reported = true;
12130
+ }
12131
+ return reported;
10920
12132
  }
10921
12133
  isFreshUnresolvedCollection(expression, type) {
10922
12134
  const unresolved = type.kind === "list" ? type.element.kind === "unknown"
@@ -10998,7 +12210,9 @@ export class Analyzer {
10998
12210
  storageType: type,
10999
12211
  span: declarationSpan,
11000
12212
  narrowingFrame: null,
12213
+ flowScope: this.scopes.length - 1,
11001
12214
  };
12215
+ this.recordScopedName(name);
11002
12216
  scope.set(name, binding);
11003
12217
  if (this.scopes.length === 1 && type.kind === "typeObject")
11004
12218
  this.runtimeTypeObjectNames.add(name);
@@ -11024,12 +12238,54 @@ export class Analyzer {
11024
12238
  }
11025
12239
  recordInitializationImportRead(binding, local, span) {
11026
12240
  const origin = this.importedBindingSources.get(binding);
11027
- if (origin === undefined || !this.inModuleInitializationPosition())
12241
+ if (origin === undefined)
12242
+ return;
12243
+ const read = { local, source: origin.source, imported: origin.imported, span };
12244
+ // D31 item 23: a read inside a deferred body belongs to that body, not to
12245
+ // the module. Whether it runs during module evaluation is decided by
12246
+ // `moduleInitializationImportReads`, once the top-level calls are known.
12247
+ const frame = this.deferredReadFrames.at(-1);
12248
+ if (frame) {
12249
+ frame.reads.push(read);
11028
12250
  return;
11029
- const key = spanIdentity(span);
11030
- if (!this.initializationImportReadSites.has(key)) {
11031
- this.initializationImportReadSites.set(key, { local, source: origin.source, imported: origin.imported, span });
11032
12251
  }
12252
+ if (!this.inModuleInitializationPosition())
12253
+ return;
12254
+ const key = spanIdentity(span);
12255
+ if (!this.initializationImportReadSites.has(key))
12256
+ this.initializationImportReadSites.set(key, read);
12257
+ }
12258
+ /**
12259
+ * D31 item 23: the call edge. Inside a deferred body it is an edge of the
12260
+ * reachability graph; at module top level it is a root, because that call
12261
+ * runs the callee while the module itself evaluates. The callee is held as
12262
+ * a binding, not as a frame — a `def` is hoisted, so `const x = pull()` can
12263
+ * be analyzed before `def pull()` is.
12264
+ */
12265
+ recordDeferredCallEdge(callee, span) {
12266
+ if (callee.kind !== "IdentifierExpression")
12267
+ return;
12268
+ // The two cheap questions first: every other call would pay for a scope
12269
+ // lookup whose answer nothing reads.
12270
+ const frame = this.deferredReadFrames.at(-1);
12271
+ if (!frame && !this.inModuleInitializationPosition())
12272
+ return;
12273
+ const binding = this.lookup(callee.name);
12274
+ if (!binding)
12275
+ return;
12276
+ if (frame)
12277
+ frame.calls.push(binding);
12278
+ else
12279
+ this.initializationLocalCalls.push({ binding, span });
12280
+ }
12281
+ /** Files an arrow's deferred frame under the module-local name it was bound to. */
12282
+ claimArrowDeferredFrame(pattern, initializer) {
12283
+ if (initializer.kind !== "ArrowFunctionExpression" || pattern.kind !== "NameBindingPattern")
12284
+ return;
12285
+ const frame = this.arrowDeferredFrames.get(spanIdentity(initializer.span));
12286
+ const binding = this.scopes.at(-1)?.get(pattern.name);
12287
+ if (frame && binding)
12288
+ this.localFunctionFrames.set(binding, frame);
11033
12289
  }
11034
12290
  // True while code at this point runs during module evaluation itself:
11035
12291
  // top-level initializers and expression statements (including nested
@@ -11047,9 +12303,52 @@ export class Analyzer {
11047
12303
  && this.instanceFieldInitializerDepth === 0
11048
12304
  && this.deferredExecutionDepth === 0;
11049
12305
  }
11050
- /** Initialization-position reads of imported bindings, for the project module-cycle check. */
12306
+ /**
12307
+ * Initialization-position reads of imported bindings, for the project
12308
+ * module-cycle check.
12309
+ *
12310
+ * D31 item 23 recorded the indirect shape as a v1 residual: a top-level call
12311
+ * of a module-local function runs that body while the module evaluates, so
12312
+ * an imported binding read inside it is an initialization-position read too
12313
+ * — and following VEL3019's own remediation ("Move this read into a
12314
+ * function") and then calling that function at top level re-created the bare
12315
+ * `ReferenceError` the check exists to delete. The closure below is the
12316
+ * intra-module reachability pass that closes it: one module, one walk over
12317
+ * the call edges already collected, no cross-module analysis.
12318
+ *
12319
+ * An indirect read is reported at the *call*, not at the read. The call is
12320
+ * the line that runs during module evaluation and the line an author can
12321
+ * move; the read inside the body is already in a function, which is what the
12322
+ * remediation asks for.
12323
+ */
11051
12324
  moduleInitializationImportReads() {
11052
- return [...this.initializationImportReadSites.values()];
12325
+ const sites = new Map(this.initializationImportReadSites);
12326
+ const visited = new Set();
12327
+ const collect = (frame, callSpan) => {
12328
+ if (visited.has(frame))
12329
+ return;
12330
+ visited.add(frame);
12331
+ for (const read of frame.reads) {
12332
+ const key = `${spanIdentity(callSpan)}\0${read.local}\0${read.source}`;
12333
+ if (!sites.has(key))
12334
+ sites.set(key, { ...read, span: callSpan });
12335
+ }
12336
+ for (const called of frame.calls) {
12337
+ const next = this.localFunctionFrames.get(called);
12338
+ if (next)
12339
+ collect(next, callSpan);
12340
+ }
12341
+ };
12342
+ for (const call of this.initializationLocalCalls) {
12343
+ const frame = this.localFunctionFrames.get(call.binding);
12344
+ // One root at a time: two roots reaching the same body must each report,
12345
+ // so the visited set is per root rather than per module.
12346
+ if (frame) {
12347
+ visited.clear();
12348
+ collect(frame, call.span);
12349
+ }
12350
+ }
12351
+ return [...sites.values()];
11053
12352
  }
11054
12353
  // D32 item 29: the language-wide text-conversion contract (charter
11055
12354
  // section 14) shared by f-strings, str(), and target-owned render sites.
@@ -11330,6 +12629,128 @@ export class Analyzer {
11330
12629
  }
11331
12630
  }
11332
12631
  }
12632
+ /**
12633
+ * D90 R12: "exported" is a property of the declaration a consumer can reach,
12634
+ * not of the `def` keyword. A module-level declaration carries the flag
12635
+ * itself and is judged here and now. A class member carries none — a public
12636
+ * member of a class this module publishes is read by a consumer exactly as
12637
+ * an exported `const` is — but whether the class is published is a question
12638
+ * about the whole module, so the member waits for reportExportPositionAny. A
12639
+ * `private` member is never reachable, and R12's boundary does not move:
12640
+ * module-internal `any` stays legal.
12641
+ */
12642
+ recordExportedAny(statement, className, span) {
12643
+ if (statement.exported === true) {
12644
+ this.reportExportedAny([statement.name], span);
12645
+ return;
12646
+ }
12647
+ if (className === null || statement.private === true)
12648
+ return;
12649
+ this.exportPositionCandidates.push({ className, member: statement.name, span });
12650
+ }
12651
+ /**
12652
+ * D90 R12: the class members that turned out to be at an export position.
12653
+ * Reported once the module is analyzed, because the answer is reachability
12654
+ * and reachability is a property of the module, not of the declaration.
12655
+ */
12656
+ reportExportPositionAny(program) {
12657
+ if (this.exportPositionCandidates.length === 0)
12658
+ return;
12659
+ const reachable = this.exportReachableClasses(program);
12660
+ for (const candidate of this.exportPositionCandidates) {
12661
+ if (reachable.has(candidate.className)) {
12662
+ this.reportExportedAny([`${candidate.className}.${candidate.member}`], candidate.span);
12663
+ }
12664
+ }
12665
+ }
12666
+ /**
12667
+ * D90 R12: which class declarations a consuming module can reach. Exported
12668
+ * classes seed the set; from there it follows every position a consumer can
12669
+ * read a value *out of* — the type of anything else this module exports, the
12670
+ * base a reachable class names, and the public surface of a class already
12671
+ * reachable. `export class Box extends Base:` publishes `Base`'s members,
12672
+ * and `def make() -> Inner` publishes `Inner`'s, whether or not either name
12673
+ * is exported.
12674
+ *
12675
+ * Input positions are deliberately absent, for the same reason
12676
+ * `typeContainsAnyOutput` omits them: a consumer that has to *supply* an
12677
+ * instance obtained it from an output position first, and that position is
12678
+ * what makes the class reachable.
12679
+ */
12680
+ exportReachableClasses(program) {
12681
+ const classes = [];
12682
+ const records = [];
12683
+ const reach = (type) => {
12684
+ if (type)
12685
+ collectOutputTypeNames(type, classes, records);
12686
+ };
12687
+ // The same walk validateReExports makes over the module's export surface,
12688
+ // so the two cannot disagree about what "this module exports" means.
12689
+ const publish = (name) => {
12690
+ reach(this.scopes[0].get(name)?.type);
12691
+ records.push(name);
12692
+ };
12693
+ for (const statement of program.body) {
12694
+ if (statement.kind === "ReExportDeclaration" || !("exported" in statement) || !statement.exported)
12695
+ continue;
12696
+ if (statement.kind === "ClassDeclaration")
12697
+ classes.push(statement.name);
12698
+ else if (statement.kind === "VariableDeclaration")
12699
+ this.collectPatternNames(statement.pattern, publish);
12700
+ else if ("name" in statement && typeof statement.name === "string")
12701
+ publish(statement.name);
12702
+ }
12703
+ const reachable = new Set();
12704
+ const visitedRecords = new Set();
12705
+ while (classes.length > 0 || records.length > 0) {
12706
+ if (records.length > 0) {
12707
+ const name = records.pop();
12708
+ if (visitedRecords.has(name))
12709
+ continue;
12710
+ visitedRecords.add(name);
12711
+ // A record a consumer holds is read field by field, so a class in a
12712
+ // field is reachable even when the record type itself is not exported.
12713
+ for (const field of this.namedTypes.get(name)?.values() ?? [])
12714
+ reach(field);
12715
+ reach(this.typeAliases.get(name));
12716
+ continue;
12717
+ }
12718
+ const name = classes.pop();
12719
+ if (reachable.has(name))
12720
+ continue;
12721
+ reachable.add(name);
12722
+ const info = this.classes.get(name);
12723
+ if (!info)
12724
+ continue;
12725
+ if (info.base)
12726
+ classes.push(info.base);
12727
+ reach(info.iterate);
12728
+ // ClassInfo is exactly the public surface — private members live in
12729
+ // their own tables — and `fields` carries the getters' result types.
12730
+ // The constructor's parameters are inputs, so they are not followed.
12731
+ for (const field of info.fields.values())
12732
+ reach(field.type);
12733
+ for (const field of info.staticFields.values())
12734
+ reach(field.type);
12735
+ for (const method of info.methods.values())
12736
+ reach(method);
12737
+ for (const method of info.staticMethods.values())
12738
+ reach(method);
12739
+ }
12740
+ return reachable;
12741
+ }
12742
+ /**
12743
+ * D90 R12: the diagnostic has to teach the way out, not only refuse. A
12744
+ * consuming module never writes `unsafe`, so an exported `any` hands it a
12745
+ * value carrying no guarantee at all; the escape is to validate the value
12746
+ * into a declared type in the module that owns the boundary, which is what
12747
+ * `Type.parse` exists for. No new diagnostic code and no unsafe marker: this
12748
+ * is the rule at validateTypeReference finished, not a second rule.
12749
+ */
12750
+ reportExportedAny(exported, span) {
12751
+ const names = exported.map((name) => `'${name}'`).join(", ");
12752
+ this.typeError(`${exported.length === 1 ? "Export" : "Exports"} ${names} ${exported.length === 1 ? "is" : "are"} 'any', which cannot cross a module boundary; validate the value into a declared type in this module first — 'const settled = Config.parse(candidate)' — and export that`, span);
12753
+ }
11333
12754
  collectPatternNames(pattern, add) {
11334
12755
  if (pattern.kind === "NameBindingPattern") {
11335
12756
  add(pattern.name);
@@ -11391,7 +12812,10 @@ export class Analyzer {
11391
12812
  : type.kind === "any" ? anyType : unknownType;
11392
12813
  const declaredElement = declaredType.kind === "list" ? declaredType.readonlyView ? this.readonlyDataViewOf(declaredType.element) : declaredType.element
11393
12814
  : declaredType.kind === "any" ? anyType : unknownType;
11394
- if (type.kind !== "list" && type.kind !== "any") {
12815
+ // An invalid source has already been reported where it went wrong —
12816
+ // D85 rule 209's "one mistake, one report" — and `describeType` would
12817
+ // render it as the bare `unknown` nobody wrote.
12818
+ if (type.kind !== "list" && type.kind !== "any" && !isInvalidType(type)) {
11395
12819
  this.typeError(`Cannot list-destructure ${describeType(type)}`, pattern.span);
11396
12820
  }
11397
12821
  for (const child of pattern.elements)
@@ -11520,7 +12944,7 @@ export class Analyzer {
11520
12944
  }
11521
12945
  else {
11522
12946
  const binding = this.lookup(key);
11523
- this.scopes.at(-1).set(key, {
12947
+ const shadow = {
11524
12948
  mutable: binding?.mutable ?? false,
11525
12949
  type,
11526
12950
  declaredType: binding?.declaredType ?? type,
@@ -11528,8 +12952,14 @@ export class Analyzer {
11528
12952
  ...(binding ? { storageBinding: binding.storageBinding ?? binding } : {}),
11529
12953
  span: binding?.span ?? narrowingSpan,
11530
12954
  narrowingFrame: this.flowFrameDepth,
12955
+ flowScope: this.scopes.length - 1,
11531
12956
  ...(binding?.reactiveKind ? { reactiveKind: binding.reactiveKind } : {}),
11532
- });
12957
+ };
12958
+ this.trackNarrowingShadow(shadow);
12959
+ this.narrowedNames.at(-1).add(key);
12960
+ if (!this.scopes.at(-1).has(key))
12961
+ this.recordScopedName(key);
12962
+ this.scopes.at(-1).set(key, shadow);
11533
12963
  }
11534
12964
  }
11535
12965
  }
@@ -11545,14 +12975,16 @@ export class Analyzer {
11545
12975
  if (!binding)
11546
12976
  continue;
11547
12977
  const local = scope.get(key);
12978
+ this.narrowedNames.at(-1).add(key);
11548
12979
  if (local) {
12980
+ this.recordFlowFactOrigin(local);
11549
12981
  local.type = type;
11550
12982
  local.narrowingFrame = this.flowFrameDepth;
11551
12983
  // A persisted (checked or merged) fact is not assignment-established.
11552
12984
  local.assignedFact = false;
11553
12985
  }
11554
12986
  else {
11555
- scope.set(key, {
12987
+ const shadow = {
11556
12988
  mutable: binding.mutable,
11557
12989
  type,
11558
12990
  declaredType: binding.declaredType,
@@ -11560,8 +12992,12 @@ export class Analyzer {
11560
12992
  storageBinding: binding.storageBinding ?? binding,
11561
12993
  span: binding.span,
11562
12994
  narrowingFrame: this.flowFrameDepth,
12995
+ flowScope: this.scopes.length - 1,
11563
12996
  ...(binding.reactiveKind ? { reactiveKind: binding.reactiveKind } : {}),
11564
- });
12997
+ };
12998
+ this.trackNarrowingShadow(shadow);
12999
+ this.recordScopedName(key);
13000
+ scope.set(key, shadow);
11565
13001
  }
11566
13002
  }
11567
13003
  }
@@ -11613,6 +13049,7 @@ export class Analyzer {
11613
13049
  const shadow = scope.get(name);
11614
13050
  if (!shadow || shadow === target || (shadow.storageBinding ?? shadow) !== storage)
11615
13051
  continue;
13052
+ this.recordFlowFactOrigin(shadow);
11616
13053
  shadow.storageType = storage.storageType;
11617
13054
  shadow.type = storage.storageType;
11618
13055
  shadow.narrowingFrame = null;
@@ -11628,13 +13065,15 @@ export class Analyzer {
11628
13065
  return;
11629
13066
  const scope = this.scopes.at(-1);
11630
13067
  const local = scope.get(name);
13068
+ this.narrowedNames.at(-1).add(name);
11631
13069
  if (local) {
13070
+ this.recordFlowFactOrigin(local);
11632
13071
  local.type = fact;
11633
13072
  local.narrowingFrame = this.flowFrameDepth;
11634
13073
  local.assignedFact = true;
11635
13074
  }
11636
13075
  else {
11637
- scope.set(name, {
13076
+ const shadow = {
11638
13077
  mutable: binding.mutable,
11639
13078
  type: fact,
11640
13079
  declaredType: binding.declaredType,
@@ -11643,8 +13082,12 @@ export class Analyzer {
11643
13082
  span: binding.span,
11644
13083
  narrowingFrame: this.flowFrameDepth,
11645
13084
  assignedFact: true,
13085
+ flowScope: this.scopes.length - 1,
11646
13086
  ...(binding.reactiveKind ? { reactiveKind: binding.reactiveKind } : {}),
11647
- });
13087
+ };
13088
+ this.trackNarrowingShadow(shadow);
13089
+ this.recordScopedName(name);
13090
+ scope.set(name, shadow);
11648
13091
  }
11649
13092
  }
11650
13093
  /** Rule 71 for member targets: establish after invalidation so the new fact survives its own write. */
@@ -11786,6 +13229,7 @@ export class Analyzer {
11786
13229
  invalidateAssignmentNarrowings(target, binding) {
11787
13230
  if (target.kind === "IdentifierExpression") {
11788
13231
  if (binding && binding.narrowingFrame !== null) {
13232
+ this.recordFlowFactOrigin(binding);
11789
13233
  binding.type = binding.storageType;
11790
13234
  binding.narrowingFrame = null;
11791
13235
  binding.assignedFact = false;
@@ -11935,25 +13379,66 @@ export class Analyzer {
11935
13379
  : null;
11936
13380
  return pattern.entries.some((entry) => this.matchPatternReflectionMayExecute(entry.pattern, fields?.get(entry.property) ?? unknownType));
11937
13381
  }
13382
+ /**
13383
+ * A snapshot used to copy every binding of every live scope, which made a
13384
+ * branch cost O(names in the module) and whole-module analysis quadratic in
13385
+ * module size. A binding nothing ever narrows cannot differ between two
13386
+ * moments, so only the bindings flow analysis has actually written are
13387
+ * visited — `flowTouched`, kept per scope depth so an exiting scope drops
13388
+ * its own, and `flowOrigins`, which remembers what each one held before its
13389
+ * first write. `flowOrigins` answers for a binding a *later* write touched
13390
+ * than the snapshot being restored: the snapshot has no entry, and its
13391
+ * pre-write state is exactly the state that snapshot recorded. A narrowing
13392
+ * shadow born after the snapshot stores `null` instead, because a full-scope
13393
+ * snapshot had nothing to restore it to either.
13394
+ */
13395
+ flowFactState(binding) {
13396
+ return {
13397
+ type: binding.type,
13398
+ storageType: binding.storageType,
13399
+ frame: binding.narrowingFrame,
13400
+ assigned: binding.assignedFact === true,
13401
+ };
13402
+ }
13403
+ /** Called immediately before flow analysis writes a binding, so the recorded state is the pre-write one. */
13404
+ recordFlowFactOrigin(binding) {
13405
+ if (this.flowOrigins.has(binding))
13406
+ return;
13407
+ this.flowOrigins.set(binding, this.flowFactState(binding));
13408
+ this.trackFlowBinding(binding);
13409
+ }
13410
+ /** A narrowing shadow created mid-flow: no older snapshot has a state for it. */
13411
+ trackNarrowingShadow(shadow) {
13412
+ this.flowOrigins.set(shadow, null);
13413
+ this.trackFlowBinding(shadow);
13414
+ }
13415
+ trackFlowBinding(binding) {
13416
+ const depth = Math.min(binding.flowScope ?? 0, this.flowTouched.length - 1);
13417
+ this.flowTouched[depth].add(binding);
13418
+ }
13419
+ /** Every binding whose flow facts may differ from another moment's, outermost scope first. */
13420
+ *touchedFlowBindings() {
13421
+ for (const level of this.flowTouched)
13422
+ yield* level;
13423
+ }
13424
+ /** The state `snapshot` recorded for `binding`, or null when it did not exist yet. */
13425
+ flowStateIn(snapshot, binding) {
13426
+ return snapshot.bindings.get(binding) ?? this.flowOrigins.get(binding) ?? null;
13427
+ }
11938
13428
  snapshotFlowFacts() {
11939
13429
  const bindings = new Map();
11940
- for (const scope of this.scopes) {
11941
- for (const binding of scope.values()) {
11942
- bindings.set(binding, {
11943
- type: binding.type,
11944
- storageType: binding.storageType,
11945
- frame: binding.narrowingFrame,
11946
- assigned: binding.assignedFact === true,
11947
- });
11948
- }
11949
- }
13430
+ for (const binding of this.touchedFlowBindings())
13431
+ bindings.set(binding, this.flowFactState(binding));
11950
13432
  return {
11951
13433
  bindings,
11952
13434
  members: this.memberNarrowings.map((scope) => new Map(scope)),
11953
13435
  };
11954
13436
  }
11955
13437
  restoreFlowFacts(snapshot) {
11956
- for (const [binding, state] of snapshot.bindings) {
13438
+ for (const binding of this.touchedFlowBindings()) {
13439
+ const state = this.flowStateIn(snapshot, binding);
13440
+ if (!state)
13441
+ continue;
11957
13442
  binding.type = state.type;
11958
13443
  binding.storageType = state.storageType;
11959
13444
  binding.narrowingFrame = state.frame;
@@ -11978,7 +13463,13 @@ export class Analyzer {
11978
13463
  flowInvalidationsSince(snapshot) {
11979
13464
  const bindings = new Set();
11980
13465
  const storageTypes = new Map();
11981
- for (const [binding, state] of snapshot.bindings) {
13466
+ // Only a written binding can carry a storage type this branch moved. One
13467
+ // nothing wrote merges its own storage type with itself, which is the
13468
+ // identity, so leaving it out of the set is what the merge already did.
13469
+ for (const binding of this.touchedFlowBindings()) {
13470
+ const state = this.flowStateIn(snapshot, binding);
13471
+ if (!state)
13472
+ continue;
11982
13473
  if (state.frame !== null
11983
13474
  && (binding.narrowingFrame !== state.frame || !sameType(binding.type, state.type)))
11984
13475
  bindings.add(binding);
@@ -11998,23 +13489,38 @@ export class Analyzer {
11998
13489
  });
11999
13490
  return { bindings, members, storageTypes };
12000
13491
  }
13492
+ /**
13493
+ * A loop's back-edge pass re-runs the whole body, and a nested loop inside
13494
+ * that pass runs its own, so the work doubled with every level of loop
13495
+ * nesting: fourteen levels of `while` in a 91-line file took 2.4 seconds and
13496
+ * seventeen took 35. The passes are budgeted by how many back-edge passes
13497
+ * are already running. Past the budget a loop analyzes its body once and its
13498
+ * exit keeps nothing — `widened` — which is what the loop would answer if
13499
+ * its back edge had falsified every fact, so the degradation only ever
13500
+ * removes a fact, never invents one. Real code does not nest loops four
13501
+ * deep, so nothing reachable by hand reaches the budget.
13502
+ */
12001
13503
  reanalyzeLoopBackEdge(baseline, visible, backEdges, body, diagnosticStart, analyze) {
12002
13504
  if (!this.flowInvalidationsAffectFacts(backEdges))
12003
- return null;
13505
+ return { repeated: null, widened: false };
13506
+ if (this.loopReanalysisDepth >= maximumLoopReanalysisDepth)
13507
+ return { repeated: null, widened: true };
12004
13508
  const loopHead = this.flowSnapshotAfterInvalidations(baseline, backEdges);
12005
13509
  this.loopFlowContexts.push({ baseline: loopHead, visible, carried: [], backEdges: [], breakFacts: [], sawBreak: false });
12006
13510
  const secondDiagnosticStart = this.diagnostics.length;
12007
13511
  this.clearCachedFlowTypes(body);
12008
13512
  let repeated = null;
13513
+ this.loopReanalysisDepth += 1;
12009
13514
  try {
12010
13515
  this.analyzeIsolatedFlow(loopHead, analyze);
12011
13516
  this.deduplicateDiagnostics(diagnosticStart, secondDiagnosticStart);
12012
13517
  }
12013
13518
  finally {
13519
+ this.loopReanalysisDepth -= 1;
12014
13520
  repeated = this.loopFlowContexts.pop() ?? null;
12015
13521
  this.restoreFlowFacts(baseline);
12016
13522
  }
12017
- return repeated;
13523
+ return { repeated, widened: false };
12018
13524
  }
12019
13525
  flowInvalidationsAffectFacts(invalidations) {
12020
13526
  return invalidations.some((item) => item.bindings.size > 0
@@ -12073,37 +13579,63 @@ export class Analyzer {
12073
13579
  return result;
12074
13580
  }
12075
13581
  visibleBindings() {
12076
- const visible = new Map();
12077
- for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
12078
- for (const [name, binding] of this.scopes[index]) {
12079
- if (!visible.has(name))
12080
- visible.set(name, binding);
12081
- }
13582
+ return this.scopes.length;
13583
+ }
13584
+ /** The binding a name resolved to when `visible` was captured. */
13585
+ visibleBinding(visible, name) {
13586
+ for (let index = Math.min(visible, this.scopes.length) - 1; index >= 0; index -= 1) {
13587
+ const binding = this.scopes[index]?.get(name);
13588
+ if (binding)
13589
+ return binding;
12082
13590
  }
12083
- return visible;
13591
+ return null;
12084
13592
  }
13593
+ /**
13594
+ * Only a name a narrowing has written can carry a fact, and `narrowedNames`
13595
+ * is the roster of those per scope — so this walks the narrowings rather
13596
+ * than every name in scope. The member half matches a dotted path against
13597
+ * the binding its root names instead of spreading the whole root set per
13598
+ * path, which is O(one lookup) rather than O(names in the module).
13599
+ */
12085
13600
  narrowingsForVisibleBindings(visible) {
12086
13601
  const narrowed = new Map();
12087
- const roots = new Set();
12088
- for (const [name, original] of visible) {
12089
- roots.add(`${original.span.start}:${name}`);
12090
- const current = this.lookup(name);
12091
- if (current?.narrowingFrame === this.flowFrameDepth
12092
- && current.span.start === original.span.start
12093
- && current.span.end === original.span.end)
12094
- narrowed.set(name, current.type);
13602
+ const seen = new Set();
13603
+ for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
13604
+ for (const name of this.narrowedNames[index]) {
13605
+ if (seen.has(name))
13606
+ continue;
13607
+ seen.add(name);
13608
+ const original = this.visibleBinding(visible, name);
13609
+ if (!original)
13610
+ continue;
13611
+ const current = this.lookup(name);
13612
+ if (current?.narrowingFrame === this.flowFrameDepth
13613
+ && current.span.start === original.span.start
13614
+ && current.span.end === original.span.end)
13615
+ narrowed.set(name, current.type);
13616
+ }
12095
13617
  }
12096
13618
  for (let index = this.memberNarrowings.length - 1; index >= 0; index -= 1) {
12097
13619
  for (const [path, fact] of this.memberNarrowings[index]) {
12098
13620
  if (fact.frame !== this.flowFrameDepth || narrowed.has(`${memberNarrowingPrefix}${path}`))
12099
13621
  continue;
12100
- if ([...roots].some((root) => path === root || path.startsWith(`${root}.`))) {
13622
+ if (this.memberNarrowingRootIsVisible(visible, path)) {
12101
13623
  narrowed.set(`${memberNarrowingPrefix}${path}`, fact.type);
12102
13624
  }
12103
13625
  }
12104
13626
  }
12105
13627
  return narrowed;
12106
13628
  }
13629
+ /** Whether a member path's root — `<declaration offset>:<name>` — names a binding visible then. */
13630
+ memberNarrowingRootIsVisible(visible, path) {
13631
+ const separator = path.indexOf(":");
13632
+ if (separator < 0)
13633
+ return false;
13634
+ const dot = path.indexOf(".");
13635
+ const start = Number(path.slice(0, separator));
13636
+ const name = path.slice(separator + 1, dot < 0 ? path.length : dot);
13637
+ return this.visibleBinding(visible, name)?.span.start === start;
13638
+ }
12107
13639
  narrowingsInSnapshot(snapshot, visible, restore) {
12108
13640
  this.restoreFlowFacts(snapshot);
12109
13641
  const narrowed = this.narrowingsForVisibleBindings(visible);
@@ -12146,6 +13678,7 @@ export class Analyzer {
12146
13678
  const candidates = branches.map((branch) => branch.storageTypes.get(binding) ?? binding.storageType);
12147
13679
  if (includeBaseline)
12148
13680
  candidates.unshift(binding.storageType);
13681
+ this.recordFlowFactOrigin(binding);
12149
13682
  binding.storageType = candidates.reduce((merged, candidate) => mergeTypes(merged, candidate));
12150
13683
  if (binding.narrowingFrame === null)
12151
13684
  binding.type = binding.storageType;
@@ -12153,6 +13686,7 @@ export class Analyzer {
12153
13686
  }
12154
13687
  for (const branch of branches) {
12155
13688
  for (const binding of branch.bindings) {
13689
+ this.recordFlowFactOrigin(binding);
12156
13690
  binding.type = binding.storageType;
12157
13691
  binding.narrowingFrame = null;
12158
13692
  binding.assignedFact = false;
@@ -12170,11 +13704,21 @@ export class Analyzer {
12170
13704
  this.scopes.push(new Map());
12171
13705
  this.memberNarrowings.push(new Map());
12172
13706
  this.pendingScopeDeclarations.push(new Map());
13707
+ this.narrowedNames.push(new Set());
13708
+ this.scopedNames.push([]);
13709
+ this.flowTouched.push(new Set());
12173
13710
  }
12174
13711
  exitScope() {
12175
13712
  this.scopes.pop();
12176
13713
  this.memberNarrowings.pop();
12177
13714
  this.pendingScopeDeclarations.pop();
13715
+ this.narrowedNames.pop();
13716
+ for (const name of this.scopedNames.pop() ?? [])
13717
+ this.nearestNames.remove(name);
13718
+ // The bindings this scope created are unreachable now, so the flow-fact
13719
+ // working set shrinks with it rather than growing across the module.
13720
+ for (const binding of this.flowTouched.pop() ?? [])
13721
+ this.flowOrigins.delete(binding);
12178
13722
  }
12179
13723
  }
12180
13724
  //# sourceMappingURL=analyzer.js.map