@velarscript/web 0.12.1 → 0.14.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 (67) hide show
  1. package/README.md +2 -2
  2. package/dist/analyzer.d.ts +164 -23
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +1254 -133
  5. package/dist/analyzer.js.map +1 -1
  6. package/dist/ast.d.ts +11 -0
  7. package/dist/ast.d.ts.map +1 -1
  8. package/dist/ast.js +22 -0
  9. package/dist/ast.js.map +1 -1
  10. package/dist/compiler.d.ts.map +1 -1
  11. package/dist/compiler.js +69 -44
  12. package/dist/compiler.js.map +1 -1
  13. package/dist/css-string.d.ts +32 -0
  14. package/dist/css-string.d.ts.map +1 -0
  15. package/dist/css-string.js +66 -0
  16. package/dist/css-string.js.map +1 -0
  17. package/dist/css-tokens.d.ts +16 -0
  18. package/dist/css-tokens.d.ts.map +1 -1
  19. package/dist/css-tokens.js +84 -0
  20. package/dist/css-tokens.js.map +1 -1
  21. package/dist/elements.d.ts +55 -0
  22. package/dist/elements.d.ts.map +1 -1
  23. package/dist/elements.js +152 -0
  24. package/dist/elements.js.map +1 -1
  25. package/dist/emitter.d.ts +10 -0
  26. package/dist/emitter.d.ts.map +1 -1
  27. package/dist/emitter.js +588 -159
  28. package/dist/emitter.js.map +1 -1
  29. package/dist/keyframes.d.ts +17 -0
  30. package/dist/keyframes.d.ts.map +1 -1
  31. package/dist/keyframes.js +28 -9
  32. package/dist/keyframes.js.map +1 -1
  33. package/dist/lexer.d.ts +21 -7
  34. package/dist/lexer.d.ts.map +1 -1
  35. package/dist/lexer.js +32 -12
  36. package/dist/lexer.js.map +1 -1
  37. package/dist/look-static.d.ts.map +1 -1
  38. package/dist/look-static.js +120 -20
  39. package/dist/look-static.js.map +1 -1
  40. package/dist/look.d.ts +15 -0
  41. package/dist/look.d.ts.map +1 -1
  42. package/dist/look.js +7 -2
  43. package/dist/look.js.map +1 -1
  44. package/dist/parser.d.ts.map +1 -1
  45. package/dist/parser.js +15 -20
  46. package/dist/parser.js.map +1 -1
  47. package/dist/runtime-foundation.d.ts.map +1 -1
  48. package/dist/runtime-foundation.js +321 -70
  49. package/dist/runtime-foundation.js.map +1 -1
  50. package/dist/runtime.d.ts.map +1 -1
  51. package/dist/runtime.js +320 -179
  52. package/dist/runtime.js.map +1 -1
  53. package/dist/stable-order.d.ts +16 -0
  54. package/dist/stable-order.d.ts.map +1 -0
  55. package/dist/stable-order.js +18 -0
  56. package/dist/stable-order.js.map +1 -0
  57. package/dist/types.d.ts +0 -1
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/types.js +0 -16
  60. package/dist/types.js.map +1 -1
  61. package/dist/websocket-runtime.d.ts.map +1 -1
  62. package/dist/websocket-runtime.js +9 -5
  63. package/dist/websocket-runtime.js.map +1 -1
  64. package/dist/worker-runtime.d.ts.map +1 -1
  65. package/dist/worker-runtime.js +9 -7
  66. package/dist/worker-runtime.js.map +1 -1
  67. package/package.json +2 -2
package/dist/analyzer.js CHANGED
@@ -2,13 +2,13 @@ import { semanticTypeIdentity } from "@velarscript/compiler";
2
2
  import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
3
3
  import { BROWSER_TEST_MODULE, BROWSER_TEST_SOURCE_SUFFIX, browserTestImportGuidance } from "./browser-test.js";
4
4
  import { cssTokens } from "./css-tokens.js";
5
- import { LOOK_ABSENT_MEDIA_SUBJECTS, LOOK_ARITHMETIC_HINT, LOOK_ANIMATION_DIRECTIONS, LOOK_ANIMATION_EASINGS, LOOK_ANIMATION_FILLS, LOOK_BORDER_STYLE_NAMES, LOOK_BUILDER_NUMERIC_RANGES, LOOK_BUILDERS, LOOK_EXCLUDED_PROPERTIES, LOOK_HOOKS, LOOK_CSS_WIDE_KEYWORDS, LOOK_LARGE_KEYWORD_SETS, LOOK_LENGTH_BUILDERS, LOOK_MEDIA_LENGTH_UNITS, LOOK_MEDIA_SUBJECTS, LOOK_NUMERIC_TYPE_NAMES, LOOK_NON_ANIMATABLE_PROPERTIES, LOOK_PARTIAL_KEYWORD_PROPERTIES, LOOK_PROPERTIES, LOOK_PROPERTY_KEYWORDS, LOOK_PROPERTY_VALUE_KINDS, LOOK_TARGETS, LOOK_UNIT_TYPES, LOOK_UNITLESS_PROPERTIES, lookOwnKeywords, nearestLookName, } from "./look.js";
6
- import { collectLookStaticValues, evaluateLookStaticExpression, isLookStaticValue } from "./look-static.js";
5
+ import { LOOK_ABSENT_MEDIA_SUBJECTS, LOOK_ARITHMETIC_HINT, LOOK_ANIMATION_DIRECTIONS, LOOK_ANIMATION_EASINGS, LOOK_ANIMATION_FILLS, LOOK_BORDER_STYLE_NAMES, LOOK_BUILDER_NUMERIC_RANGES, LOOK_BUILDER_SIGNATURES, LOOK_BUILDERS, LOOK_EXCLUDED_PROPERTIES, LOOK_HOOKS, LOOK_CSS_WIDE_KEYWORDS, LOOK_LARGE_KEYWORD_SETS, LOOK_LENGTH_BUILDERS, LOOK_MEDIA_LENGTH_UNITS, LOOK_MEDIA_SUBJECTS, LOOK_NUMERIC_TYPE_NAMES, LOOK_NON_ANIMATABLE_PROPERTIES, LOOK_PARTIAL_KEYWORD_PROPERTIES, LOOK_PROPERTIES, LOOK_PROPERTY_KEYWORDS, LOOK_PROPERTY_VALUE_KINDS, LOOK_TARGETS, LOOK_UNIT_TYPES, LOOK_UNITLESS_PROPERTIES, lookOwnKeywords, nearestLookName, } from "./look.js";
6
+ import { collectLookStaticValues, evaluateLookStaticExpression, isLookStaticValue, lookStaticCss } from "./look-static.js";
7
7
  import { keyframeCssValue } from "./keyframes.js";
8
8
  import { dynamicChildLeaves } from "./emitter.js";
9
- import { isWebCustomElementName, WEB_NATIVE_ELEMENTS } from "./elements.js";
9
+ import { isWebCustomElementName, WEB_ARIA_ATTRIBUTES, WEB_ARIA_ENUMERATED_VALUES, WEB_ARIA_ROLES, WEB_ARIA_ROLE_SYNONYMS, WEB_MISSPELLED_ATTRIBUTES, WEB_NATIVE_ELEMENTS, } from "./elements.js";
10
10
  import { isWebExpression, isWebJsx, isWebKeyframes, isWebLook, isWebStatement, isWebUnit, } from "./ast.js";
11
- import { CACHED_INTRINSIC_TYPE, isWebComponentConstructor, isWebComponentType, isWebComputedExport, isWebNodeType, normalizeWebComponentType, webComponentConstructor, webComponentHandle, webComponentIntrinsic, webComponentName, webNodeType, WEB_EVENT_TYPE_NAMES, WEB_OWNED_TYPE_NAMES, } from "./types.js";
11
+ import { isWebComponentConstructor, isWebComponentType, isWebComputedExport, isWebNodeType, normalizeWebComponentType, webComponentConstructor, webComponentHandle, webComponentIntrinsic, webComponentName, webNodeType, WEB_EVENT_TYPE_NAMES, WEB_OWNED_TYPE_NAMES, } from "./types.js";
12
12
  // The canonical nominal identity of the Web RouteContext record. Route checks
13
13
  // probe with this identity so they succeed in modules that use route() without
14
14
  // importing RouteContext by name.
@@ -24,6 +24,33 @@ const nativeDomEventNames = new Set([
24
24
  "compositionstart", "compositionupdate", "compositionend",
25
25
  "copy", "cut", "paste", "load", "error", "transitionend", "animationend", "play", "pause", "ended",
26
26
  ]);
27
+ // The attribute names an element really compiles as script: the browser turns
28
+ // the attribute's text into a function body in the document's origin. This is a
29
+ // different roster from `nativeDomEventNames` above, which answers "is there an
30
+ // `on:` directive worth naming for this remainder". The two used to be one set,
31
+ // so `onanimationstart` was told its name is merely reserved while the browser
32
+ // was compiling it, and before that `onward` was told the reverse. Lookups
33
+ // lowercase the written name, because an HTML attribute name is matched
34
+ // ASCII-case-insensitively. Window-reflecting handlers (`onstorage`,
35
+ // `onmessage`, `onunload`) stay out: they take effect only on `<body>`, so the
36
+ // clause would be false of the element the author actually wrote.
37
+ const htmlEventHandlerAttributes = new Set([
38
+ "onabort", "onauxclick", "onbeforeinput", "onbeforematch", "onbeforetoggle", "onblur", "oncancel",
39
+ "oncanplay", "oncanplaythrough", "onchange", "onclick", "onclose", "oncontextlost", "oncontextmenu",
40
+ "oncontextrestored", "oncopy", "oncuechange", "oncut", "ondblclick", "ondrag", "ondragend", "ondragenter",
41
+ "ondragleave", "ondragover", "ondragstart", "ondrop", "ondurationchange", "onemptied", "onended", "onerror",
42
+ "onfocus", "onformdata", "oninput", "oninvalid", "onkeydown", "onkeypress", "onkeyup", "onload",
43
+ "onloadeddata", "onloadedmetadata", "onloadstart", "onmousedown", "onmouseenter", "onmouseleave",
44
+ "onmousemove", "onmouseout", "onmouseover", "onmouseup", "onpaste", "onpause", "onplay", "onplaying",
45
+ "onprogress", "onratechange", "onreset", "onresize", "onscroll", "onscrollend", "onsecuritypolicyviolation",
46
+ "onseeked", "onseeking", "onselect", "onslotchange", "onstalled", "onsubmit", "onsuspend", "ontimeupdate",
47
+ "ontoggle", "onvolumechange", "onwaiting", "onwheel",
48
+ "onanimationstart", "onanimationend", "onanimationiteration", "onanimationcancel",
49
+ "ontransitionrun", "ontransitionstart", "ontransitionend", "ontransitioncancel",
50
+ "onpointerdown", "onpointerup", "onpointermove", "onpointerover", "onpointerout", "onpointerenter",
51
+ "onpointerleave", "onpointercancel", "ongotpointercapture", "onlostpointercapture",
52
+ "ontouchstart", "ontouchend", "ontouchmove", "ontouchcancel",
53
+ ]);
27
54
  const textualWebPrimitiveNames = new Set(["Length", "Percentage", "LengthPercentage", "TrackFraction", "Color", "Duration", "Angle"]);
28
55
  // D72 rule 186: derived from the published table, not restated beside it.
29
56
  const webEventTypeNames = WEB_EVENT_TYPE_NAMES;
@@ -96,19 +123,17 @@ function storageReadGuidance(call) {
96
123
  export function inferWebIntrinsic(context) {
97
124
  const { intrinsic, argumentAt, callSpan, arity, inferAt, callbackAt, runtimeTypeAt } = context;
98
125
  switch (intrinsic.name) {
99
- case "reactive.computed": {
126
+ case RETIRED_ACCESSOR_INTRINSIC: {
127
+ // A call around the retired `computed(...)` returns the reader it
128
+ // always returned, carrying the callback's own result so an annotated
129
+ // declaration reads its migration message and nothing else. Nothing else
130
+ // is checked here — the shape is already refused, and a second complaint
131
+ // about a spelling that no longer exists teaches the author nothing.
100
132
  arity(1, 1);
101
133
  const callback = callbackAt(0, [], unknownType);
102
- if (callback.kind === "any")
103
- return { kind: "function", parameters: [], requiredParameters: 0, result: anyType };
104
- if (callback.kind !== "function" && callback.kind !== "intrinsic") {
105
- context.typeError("cached requires a synchronous zero-argument function", argumentAt(0)?.span ?? callSpan);
106
- return { kind: "function", parameters: [], requiredParameters: 0, result: unknownType };
107
- }
108
- const result = callback.result;
109
- if (context.expandAliases(result).kind === "promise") {
110
- context.typeError("cached cannot hold a Promise; load asynchronous data with a resource", argumentAt(0)?.span ?? callSpan);
111
- }
134
+ const result = callback.kind === "function" || callback.kind === "intrinsic" || callback.kind === "action"
135
+ ? callback.result
136
+ : callback.kind === "any" ? anyType : unknownType;
112
137
  return { kind: "function", parameters: [], requiredParameters: 0, result };
113
138
  }
114
139
  case "web.route": {
@@ -117,7 +142,7 @@ export function inferWebIntrinsic(context) {
117
142
  const component = inferAt(1);
118
143
  const path = argumentAt(0);
119
144
  if (path?.kind === "LiteralExpression" && typeof path.value === "string")
120
- checkRoutePath(path.value, path.span, context);
145
+ checkRoutePath(path.value, path.span, context.typeError);
121
146
  checkRouteComponent(component, argumentAt(1)?.span ?? callSpan, "A route", context);
122
147
  return { kind: "object", fields: new Map([["path", stringType], ["component", component]]) };
123
148
  }
@@ -134,13 +159,17 @@ export function inferWebIntrinsic(context) {
134
159
  }
135
160
  if (isInvalidType(loader))
136
161
  return loader;
137
- if (loader.kind !== "function" && loader.kind !== "any") {
162
+ // No `any` arm on either line: the gate above has already established
163
+ // that argument 0 is a zero-parameter arrow, and the core analyzer infers
164
+ // an arrow on one return path that always answers `kind: "function"`
165
+ // (`inferArrow`). The loader is therefore never `any`, and D90 R17 —
166
+ // which retired the JavaScript boundary that used to hand one back — left
167
+ // nothing that could make it one.
168
+ if (loader.kind !== "function") {
138
169
  if (loaderExpression)
139
170
  context.typeError(`Expected a module loader, received ${describeType(loader)}`, loaderExpression.span);
140
171
  return anyType;
141
172
  }
142
- if (loader.kind === "any")
143
- return anyType;
144
173
  if (isInvalidType(loader.result))
145
174
  return loader.result;
146
175
  if (loader.parameters.length !== 0)
@@ -222,14 +251,6 @@ export function inferWebIntrinsic(context) {
222
251
  }
223
252
  return intrinsic.result;
224
253
  }
225
- case "realtime.sendJson": {
226
- arity(1, 1);
227
- const value = inferAt(0);
228
- const valueExpression = argumentAt(0);
229
- if (context.jsonSerializable(value) === false && valueExpression)
230
- context.typeError(`Realtime JSON accepts only records, Lists, enums, primitives, and optionals; received ${describeType(value)}`, valueExpression.span);
231
- return nullType;
232
- }
233
254
  case "config.public":
234
255
  arity(1, 1);
235
256
  return runtimeTypeAt(0);
@@ -460,16 +481,61 @@ function lookVocabularyGuidance(property, written, own, lead) {
460
481
  named: false,
461
482
  };
462
483
  }
463
- function literalLookStrings(expression) {
484
+ /**
485
+ * Every string this expression can be when it is written out of literals alone
486
+ * — one literal, or a ternary whose branches are literals all the way down.
487
+ * Answers null when a value is decided anywhere else, which is the signal that
488
+ * a check reading it has nothing static to look at.
489
+ */
490
+ function literalStringValues(expression) {
464
491
  if (expression.kind === "LiteralExpression")
465
492
  return typeof expression.value === "string" ? [expression.value] : [];
466
493
  if (expression.kind === "ConditionalExpression") {
467
- const thenValues = literalLookStrings(expression.thenValue);
468
- const elseValues = literalLookStrings(expression.elseValue);
494
+ const thenValues = literalStringValues(expression.thenValue);
495
+ const elseValues = literalStringValues(expression.elseValue);
469
496
  return thenValues && elseValues ? [...thenValues, ...elseValues] : null;
470
497
  }
471
498
  return null;
472
499
  }
500
+ // WEB-S2: the attributes whose value the browser resolves as a URL and then
501
+ // navigates, fetches, or submits to. A `javascript:`/`vbscript:` string in one
502
+ // of them is script the page runs, which is the boundary the charter reserves
503
+ // for `unsafe:html` — so the scheme is checked wherever it is written down.
504
+ const WEB_URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "poster", "data", "xlink:href", "ping", "cite"]);
505
+ const WEB_SCRIPT_URL_SCHEMES = new Set(["javascript", "vbscript"]);
506
+ // A `data:` URL is a document the browser builds from the string itself, so it
507
+ // is inert only for the media types that cannot carry script. `image/svg+xml`
508
+ // is deliberately absent: an SVG document runs script.
509
+ const WEB_INERT_DATA_MEDIA_TYPES = new Set([
510
+ "image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/bmp", "image/x-icon",
511
+ "video/mp4", "video/webm", "video/ogg", "audio/mpeg", "audio/ogg", "audio/wav", "audio/webm",
512
+ "font/woff", "font/woff2", "font/ttf", "font/otf", "text/plain", "text/css",
513
+ ]);
514
+ /**
515
+ * The scheme a browser reads out of a URL string. Leading and embedded ASCII
516
+ * whitespace and control characters are stripped first, because the URL parser
517
+ * strips them too — `java\tscript:alert(1)` is a `javascript:` URL.
518
+ */
519
+ function urlAttributeScheme(value) {
520
+ const stripped = value.replace(/[\u0000-\u0020]/gu, "");
521
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):/u.exec(stripped);
522
+ return match ? { scheme: match[1].toLowerCase(), rest: stripped.slice(match[0].length) } : null;
523
+ }
524
+ /** The clause naming why one literal URL is refused, or null when it is fine. */
525
+ function urlSchemeRefusal(value) {
526
+ const parsed = urlAttributeScheme(value);
527
+ if (!parsed)
528
+ return null;
529
+ if (WEB_SCRIPT_URL_SCHEMES.has(parsed.scheme)) {
530
+ return `'${parsed.scheme}:' is script, not a location — write an 'on:click' handler for behavior, or a real URL for navigation`;
531
+ }
532
+ if (parsed.scheme !== "data")
533
+ return null;
534
+ const media = (/^([^,;]*)/u.exec(parsed.rest)?.[1] ?? "").toLowerCase();
535
+ if (WEB_INERT_DATA_MEDIA_TYPES.has(media))
536
+ return null;
537
+ return `a 'data:' URL is only accepted for a media type that cannot carry script${media ? `, and '${media}' can` : ""}; the inert types are ${[...WEB_INERT_DATA_MEDIA_TYPES].join(", ")}`;
538
+ }
473
539
  function isViewportComparison(expression) {
474
540
  if (expression.kind !== "BinaryExpression" || !["<", "<=", ">", ">="].includes(expression.operator))
475
541
  return false;
@@ -525,21 +591,20 @@ function lookSourceOf(expression) {
525
591
  return "breakpoint";
526
592
  }
527
593
  /**
528
- * A canonical rendering of one Look condition, so two sibling blocks that lower
529
- * to the same selector and media query share a duplicate-detection scope.
594
+ * A syntactic rendering of one condition operand, used for the part of a Look
595
+ * condition that does not lower to a selector or a media query: a runtime term
596
+ * keeps its own scope, because two different runtime conditions really are two
597
+ * different scopes.
530
598
  */
531
- function lookConditionSignature(expression) {
599
+ function lookRuntimeSignature(expression) {
532
600
  if (isWebExpression(expression) && expression.kind === "ExtensionExpression:web:look-hook")
533
601
  return `@${expression.name}`;
534
602
  if (expression.kind === "UnaryExpression" && expression.operator === "not")
535
- return `!(${lookConditionSignature(expression.operand)})`;
536
- if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")) {
537
- return `(${lookConditionSignature(expression.left)}${expression.operator}${lookConditionSignature(expression.right)})`;
538
- }
603
+ return `!(${lookRuntimeSignature(expression.operand)})`;
539
604
  if (expression.kind === "BinaryExpression")
540
- return `(${lookConditionSignature(expression.left)}${expression.operator}${lookConditionSignature(expression.right)})`;
605
+ return `(${lookRuntimeSignature(expression.left)}${expression.operator}${lookRuntimeSignature(expression.right)})`;
541
606
  if (expression.kind === "MemberExpression")
542
- return `${lookConditionSignature(expression.object)}.${expression.property}`;
607
+ return `${lookRuntimeSignature(expression.object)}.${expression.property}`;
543
608
  if (expression.kind === "IdentifierExpression")
544
609
  return `id:${expression.name}`;
545
610
  if (isWebUnit(expression))
@@ -548,11 +613,87 @@ function lookConditionSignature(expression) {
548
613
  return `lit:${expression.raw}`;
549
614
  return `span:${expression.span.start}`;
550
615
  }
616
+ function lookKebab(value) {
617
+ return value.replace(/[A-Z]/gu, (character) => `-${character.toLowerCase()}`);
618
+ }
619
+ const lookOperatorNames = new Map([["<", "lt"], ["<=", "lte"], [">", "gt"], [">=", "gte"]]);
620
+ const lookNegatedOperators = new Map([["<", ">="], ["<=", ">"], [">", "<="], [">=", "<"]]);
621
+ /**
622
+ * LOK-I5: the duplicate-property scope used to key on the *written* condition,
623
+ * while the emitter keys its rules on the *lowered* one. `if scheme.dark:` and
624
+ * `if not scheme.light:` are the same condition by the charter's own words, so
625
+ * one silently overwrote the other and the loser was never reported. These
626
+ * atoms mirror the emitter's lowering exactly — a scheme and a reduced-motion
627
+ * negation name the other side of the query, and a negated breakpoint flips its
628
+ * operator — so the scope key is the token the rule ends up carrying.
629
+ */
630
+ function lookConditionAtom(expression, negated, staticValues) {
631
+ if (isWebExpression(expression) && expression.kind === "ExtensionExpression:web:look-hook") {
632
+ return `${negated ? "not-" : ""}${lookKebab(expression.name)}`;
633
+ }
634
+ if (expression.kind === "MemberExpression" && expression.object.kind === "IdentifierExpression") {
635
+ if (expression.object.name === "motion") {
636
+ return expression.property === "reduced" ? `motion-${negated ? "no-preference" : "reduce"}` : null;
637
+ }
638
+ if (expression.object.name === "scheme" && (expression.property === "dark" || expression.property === "light")) {
639
+ return `scheme-${negated ? (expression.property === "dark" ? "light" : "dark") : expression.property}`;
640
+ }
641
+ return null;
642
+ }
643
+ if (!isViewportComparison(expression))
644
+ return null;
645
+ const comparison = expression;
646
+ const threshold = evaluateLookStaticExpression(comparison.right, staticValues);
647
+ if (threshold?.kind !== "unit" || !LOOK_MEDIA_LENGTH_UNITS.has(threshold.unit))
648
+ return null;
649
+ const property = comparison.left.property;
650
+ const operator = negated ? lookNegatedOperators.get(comparison.operator) : comparison.operator;
651
+ return `viewport-${property}-${lookOperatorNames.get(operator)}-${lookStaticCss(threshold)}`;
652
+ }
653
+ /**
654
+ * One Look condition as the set of alternatives it lowers to, each alternative
655
+ * being the sorted atoms that must hold together. Two conditions share a
656
+ * duplicate-detection scope exactly when this rendering matches, so a condition
657
+ * written the other way round, negated into its complement, or spelled with its
658
+ * operands swapped lands in the scope its rule will actually occupy.
659
+ */
660
+ function lookConditionKey(expression, negated, staticValues) {
661
+ const terms = lookConditionTerms(expression, negated, staticValues);
662
+ return terms.map((term) => [...term].sort().join("+")).sort().join("|");
663
+ }
664
+ function lookConditionTerms(expression, negated, staticValues) {
665
+ if (expression.kind === "UnaryExpression" && expression.operator === "not") {
666
+ return lookConditionTerms(expression.operand, !negated, staticValues);
667
+ }
668
+ if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")) {
669
+ const conjunction = (expression.operator === "and") !== negated;
670
+ const left = lookConditionTerms(expression.left, negated, staticValues);
671
+ const right = lookConditionTerms(expression.right, negated, staticValues);
672
+ if (!conjunction)
673
+ return [...left, ...right].slice(0, LOOK_CONDITION_TERM_LIMIT);
674
+ const combined = [];
675
+ for (const first of left) {
676
+ for (const second of right) {
677
+ combined.push([...first, ...second]);
678
+ if (combined.length >= LOOK_CONDITION_TERM_LIMIT)
679
+ return combined;
680
+ }
681
+ }
682
+ return combined;
683
+ }
684
+ const atom = lookConditionAtom(expression, negated, staticValues);
685
+ return [[atom ?? `rt:${negated ? "!" : ""}${lookRuntimeSignature(expression)}`]];
686
+ }
551
687
  /**
552
688
  * Every name in a module or component body whose read is reactive but whose
553
689
  * binding is not itself a state/prop reference: a computed accessor, a resource
554
690
  * handle, an action handle. A Look literal that reads one of these freezes it
555
691
  * exactly as it freezes a state read (LOK-D1).
692
+ *
693
+ * The retired `computed(...)` accessor stays in the callee test even though it
694
+ * is not a global any more (D71 replaced it with the declaration): analysis
695
+ * after a migration diagnostic still has to stay coherent, so the name a
696
+ * retired declaration binds is treated as derived exactly as it was before.
556
697
  */
557
698
  function collectDerivedReactiveNames(program) {
558
699
  const names = new Set();
@@ -560,7 +701,7 @@ function collectDerivedReactiveNames(program) {
560
701
  for (const statement of statements) {
561
702
  if (statement.kind === "VariableDeclaration" && statement.pattern.kind === "NameBindingPattern"
562
703
  && statement.initializer.kind === "CallExpression" && statement.initializer.callee.kind === "IdentifierExpression"
563
- && (statement.initializer.callee.name === "cached" || statement.initializer.callee.name === "computed")) {
704
+ && isRetiredAccessorName(statement.initializer.callee.name)) {
564
705
  names.add(statement.pattern.name);
565
706
  continue;
566
707
  }
@@ -575,6 +716,74 @@ function collectDerivedReactiveNames(program) {
575
716
  record(program.body);
576
717
  return names;
577
718
  }
719
+ /**
720
+ * Every `const name = look:` in the module, wherever it is written. A name
721
+ * declared twice maps to null: two different Look literals under one name make
722
+ * the composition question unanswerable, so the check that reads this map
723
+ * declines rather than guessing which one an element received.
724
+ */
725
+ function collectLookDeclarations(program) {
726
+ const looks = new Map();
727
+ const visit = (value) => {
728
+ if (Array.isArray(value)) {
729
+ for (const item of value)
730
+ visit(item);
731
+ return;
732
+ }
733
+ if (!value || typeof value !== "object")
734
+ return;
735
+ const record = value;
736
+ if (record.kind === "VariableDeclaration") {
737
+ const declaration = record;
738
+ if (declaration.binding === "const" && declaration.pattern.kind === "NameBindingPattern" && isWebLook(declaration.initializer)) {
739
+ looks.set(declaration.pattern.name, looks.has(declaration.pattern.name) ? null : declaration.initializer);
740
+ }
741
+ }
742
+ for (const [key, child] of Object.entries(record))
743
+ if (key !== "span")
744
+ visit(child);
745
+ };
746
+ visit(program.body);
747
+ return looks;
748
+ }
749
+ /**
750
+ * The properties one Look sets, each keyed by the target it sets them on so a
751
+ * `@before:` colour and an element colour stay two different decisions. The
752
+ * condition is deliberately not part of the key: two looks that set one
753
+ * property under conditions that can both hold are exactly the ambiguity this
754
+ * serves. A `...spread` of another look is followed, and the names followed are
755
+ * reported back, because composing is what makes two looks ordered rather than
756
+ * independent.
757
+ */
758
+ function lookContributions(look, looks, visited = new Set()) {
759
+ const properties = new Set();
760
+ const composed = new Set();
761
+ const walk = (entries, target) => {
762
+ for (const entry of entries) {
763
+ if (entry.kind === "LookProperty")
764
+ properties.add(`${target}:${entry.name}`);
765
+ else if (entry.kind === "LookIf") {
766
+ walk(entry.thenEntries, target);
767
+ walk(entry.elseEntries, target);
768
+ }
769
+ else if (entry.kind === "LookTarget")
770
+ walk(entry.entries, entry.name);
771
+ else if (entry.kind === "LookSpread" && entry.value.kind === "IdentifierExpression" && !visited.has(entry.value.name)) {
772
+ const source = looks.get(entry.value.name);
773
+ composed.add(entry.value.name);
774
+ if (!source)
775
+ continue;
776
+ const inner = lookContributions(source, looks, new Set([...visited, entry.value.name]));
777
+ for (const property of inner.properties)
778
+ properties.add(property);
779
+ for (const name of inner.composed)
780
+ composed.add(name);
781
+ }
782
+ }
783
+ };
784
+ walk(look.entries, "");
785
+ return { properties, composed };
786
+ }
578
787
  /** Local names bound to a velar/look builder, including aliased imports. */
579
788
  function collectLookBuilderNames(program) {
580
789
  const names = new Map();
@@ -669,37 +878,37 @@ function checkRouteComponent(type, sourceSpan, subject, context) {
669
878
  if (routeProp && !context.isAssignable({ kind: "named", name: "RouteContext", identity: routeContextIdentity }, routeProp))
670
879
  context.typeError(`${subject} component's route prop must accept RouteContext, received ${describeType(routeProp)}`, sourceSpan);
671
880
  }
672
- function checkRoutePath(path, sourceSpan, context) {
881
+ function checkRoutePath(path, sourceSpan, report) {
673
882
  if (!path.startsWith("/"))
674
- context.typeError("A route path must start with '/'", sourceSpan);
883
+ report("A route path must start with '/'", sourceSpan);
675
884
  if (path.includes("?") || path.includes("#"))
676
- context.typeError("A route path describes only a pathname; read query and hash from RouteContext", sourceSpan);
885
+ report("A route path describes only a pathname; read query and hash from RouteContext", sourceSpan);
677
886
  if (path.includes("\\"))
678
- context.typeError("A route path cannot contain a backslash", sourceSpan);
887
+ report("A route path cannot contain a backslash", sourceSpan);
679
888
  if (path.length > 1 && path.endsWith("/"))
680
- context.typeError("A route path cannot end with '/'; matching already accepts one trailing slash", sourceSpan);
889
+ report("A route path cannot end with '/'; matching already accepts one trailing slash", sourceSpan);
681
890
  const segments = path.split("/").slice(1);
682
891
  const parameters = new Set();
683
892
  for (const [index, segment] of segments.entries()) {
684
893
  if (segment.length === 0 && path !== "/")
685
- context.typeError("A route path cannot contain an empty segment", sourceSpan);
894
+ report("A route path cannot contain an empty segment", sourceSpan);
686
895
  if (segment === "*") {
687
896
  if (index !== segments.length - 1)
688
- context.typeError("A route wildcard must be the final segment", sourceSpan);
897
+ report("A route wildcard must be the final segment", sourceSpan);
689
898
  if (parameters.has("wildcard"))
690
- context.typeError("A route parameter named 'wildcard' conflicts with the '*' capture", sourceSpan);
899
+ report("A route parameter named 'wildcard' conflicts with the '*' capture", sourceSpan);
691
900
  parameters.add("wildcard");
692
901
  continue;
693
902
  }
694
903
  if (segment.includes("*"))
695
- context.typeError("A route wildcard must occupy its whole final segment", sourceSpan);
904
+ report("A route wildcard must occupy its whole final segment", sourceSpan);
696
905
  if (!segment.startsWith(":"))
697
906
  continue;
698
907
  const name = segment.slice(1);
699
908
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))
700
- context.typeError("A route parameter requires a valid name", sourceSpan);
909
+ report("A route parameter requires a valid name", sourceSpan);
701
910
  else if (parameters.has(name))
702
- context.typeError(`Route parameter '${name}' is repeated`, sourceSpan);
911
+ report(`Route parameter '${name}' is repeated`, sourceSpan);
703
912
  parameters.add(name);
704
913
  }
705
914
  }
@@ -744,12 +953,446 @@ function hasAccessibleSvgName(expression) {
744
953
  return expression.children.some((child) => child.kind === "ExtensionExpression:web:jsx"
745
954
  && child.tag === "title" && hasAccessibleJsxContent(child));
746
955
  }
956
+ /**
957
+ * `computed(...)` the function is answered with the signature it always had, so
958
+ * a call written around it still type-checks instead of collapsing into an
959
+ * unknown one — that is what keeps the one message about the declaration form
960
+ * the only thing the author reads. `reactive.computed` itself is gone; this one
961
+ * derives no reactivity and is never emitted, because every site that produces
962
+ * it also produces an error. It stays an intrinsic only so the reader it
963
+ * returns carries the callback's own result: an annotated declaration —
964
+ * `const one: () -> number = computed(() => 1)` — would otherwise read a
965
+ * second, spurious assignability error on top of it.
966
+ */
967
+ const RETIRED_ACCESSOR_INTRINSIC = "reactive.retired-accessor";
968
+ const retiredAccessorReaderType = Object.freeze({ kind: "function", parameters: [], requiredParameters: 0, result: unknownType });
969
+ const RETIRED_ACCESSOR_TYPE = Object.freeze({
970
+ kind: "intrinsic",
971
+ name: RETIRED_ACCESSOR_INTRINSIC,
972
+ parameterNames: ["read"],
973
+ parameters: [retiredAccessorReaderType],
974
+ requiredParameters: 1,
975
+ result: retiredAccessorReaderType,
976
+ });
977
+ /** The function spelling of a derived value; `computed` the declaration is what it becomes. */
978
+ function isRetiredAccessorName(name) {
979
+ return name === "computed";
980
+ }
981
+ /**
982
+ * D90 R15(a): a watch subject names a place in the reactive graph — the name of
983
+ * a `state` or a `computed`, or a read path out of one — and never computes a
984
+ * value. The rule is written positively, as this allowlist, because that is
985
+ * what makes the relation between a watch and its source *declared* rather than
986
+ * inferred; every earlier attempt to infer it was defeated by one more
987
+ * indirection. A path is legal at any depth and with any index expression:
988
+ * `rows[i].cells[j]` still selects a place, and whether `i` is a constant or
989
+ * another state changes nothing about that. An operator or a call derives a new
990
+ * value instead, and a derived value already has a spelling that names it,
991
+ * caches it, and declares its dependencies. The conditional is an operator by
992
+ * the charter's own vocabulary, and an f-string builds a new string, so neither
993
+ * needs a clause of its own — they simply are not paths.
994
+ *
995
+ * Whether the root of a legal path is reactive is a different question, and
996
+ * `frozenWatchSubject` keeps it: this answers shape only.
997
+ */
998
+ function watchSubjectPath(expression) {
999
+ switch (expression.kind) {
1000
+ case "IdentifierExpression":
1001
+ return true;
1002
+ case "MemberExpression":
1003
+ case "IndexExpression":
1004
+ return watchSubjectPath(expression.object);
1005
+ default:
1006
+ return false;
1007
+ }
1008
+ }
1009
+ /** The escapes a text literal carries back into source (`scanStringEscape`). */
1010
+ const WATCH_SUBJECT_TEXT_ESCAPES = {
1011
+ "\\": "\\\\",
1012
+ "\"": "\\\"",
1013
+ "\n": "\\n",
1014
+ "\r": "\\r",
1015
+ "\t": "\\t",
1016
+ };
1017
+ /**
1018
+ * A text literal written back as source. `LiteralExpression.raw` holds the
1019
+ * decoded content without its quotes, so echoing it would quote an expression
1020
+ * the author never wrote and hand him a `computed` line that does not compile —
1021
+ * `watch term + "!":` would read back as `term + !`. The quote the author chose
1022
+ * is not on the node, so every text literal reads back double-quoted; that is a
1023
+ * spelling difference, not a meaning one, and it compiles.
1024
+ */
1025
+ function renderWatchTextLiteral(value, insideFString) {
1026
+ let text = "";
1027
+ for (const character of value) {
1028
+ const escape = WATCH_SUBJECT_TEXT_ESCAPES[character];
1029
+ if (escape !== undefined) {
1030
+ text += escape;
1031
+ continue;
1032
+ }
1033
+ // `{{` and `}}` are how an f-string carries a brace that is not a hole.
1034
+ if (insideFString && (character === "{" || character === "}")) {
1035
+ text += `${character}${character}`;
1036
+ continue;
1037
+ }
1038
+ const code = character.codePointAt(0);
1039
+ text += code < 0x20 || code === 0x7f ? `\\u{${code.toString(16)}}` : character;
1040
+ }
1041
+ return text;
1042
+ }
1043
+ /**
1044
+ * The subject as the author wrote it, reconstructed from the AST for exactly
1045
+ * the node kinds a subject can be. The analyzer never sees the module text, and
1046
+ * a refusal that cannot quote what it refused teaches nothing — so the message
1047
+ * quotes this. It is a reconstruction and stays one: it is used in the message
1048
+ * only, never in a `fix`, because rewriting an author's source from a
1049
+ * reconstruction is a worse defect than the one being reported. Null means
1050
+ * "cannot say faithfully", and the message drops the echo rather than guess.
1051
+ *
1052
+ * Faithfulness is the whole point, so grouping is carried across rather than
1053
+ * re-derived: the three nodes that record the author's own parentheses are
1054
+ * printed with them, and the low-precedence nodes that record nothing are
1055
+ * parenthesized wherever a bare printing would re-associate. The reconstruction
1056
+ * therefore parses back to the tree it came from.
1057
+ */
1058
+ function renderWatchSubject(expression) {
1059
+ switch (expression.kind) {
1060
+ case "IdentifierExpression":
1061
+ return expression.name;
1062
+ case "LiteralExpression":
1063
+ return typeof expression.value === "string" ? `"${renderWatchTextLiteral(expression.value, false)}"` : expression.raw;
1064
+ case "MemberExpression": {
1065
+ const object = renderWatchSubjectOperand(expression.object);
1066
+ return object === null ? null : `${object}${expression.optional ? "?." : "."}${expression.property}`;
1067
+ }
1068
+ case "IndexExpression": {
1069
+ const object = renderWatchSubjectOperand(expression.object);
1070
+ const index = renderWatchSubject(expression.index);
1071
+ return object === null || index === null ? null : `${object}${expression.optional ? "?" : ""}[${index}]`;
1072
+ }
1073
+ case "CallExpression": {
1074
+ const callee = renderWatchSubjectOperand(expression.callee);
1075
+ if (callee === null)
1076
+ return null;
1077
+ const argumentTexts = [];
1078
+ for (const [index, argument] of expression.arguments.entries()) {
1079
+ const rendered = renderWatchSubject(argument);
1080
+ if (rendered === null)
1081
+ return null;
1082
+ const name = expression.argumentNames?.[index] ?? null;
1083
+ argumentTexts.push(name === null ? rendered : `${name} = ${rendered}`);
1084
+ }
1085
+ return `${callee}${expression.optional ? "?" : ""}(${argumentTexts.join(", ")})`;
1086
+ }
1087
+ case "SpreadExpression": {
1088
+ const value = renderWatchSubjectOperand(expression.value);
1089
+ return value === null ? null : `...${value}`;
1090
+ }
1091
+ case "RequiredExpression": {
1092
+ const value = renderWatchSubjectOperand(expression.value);
1093
+ return value === null ? null : `${value}!`;
1094
+ }
1095
+ case "TryExpression": {
1096
+ const value = renderWatchSubjectOperand(expression.value);
1097
+ return value === null ? null : `try ${value}`;
1098
+ }
1099
+ case "ListExpression": {
1100
+ const elements = expression.elements.map((element) => renderWatchSubject(element));
1101
+ if (elements.some((element) => element === null))
1102
+ return null;
1103
+ return `[${elements.join(", ")}]`;
1104
+ }
1105
+ case "ObjectExpression": {
1106
+ const entries = [];
1107
+ for (const property of expression.properties) {
1108
+ if (property.kind === "ObjectSpread") {
1109
+ const value = renderWatchSubjectOperand(property.value);
1110
+ if (value === null)
1111
+ return null;
1112
+ entries.push(`...${value}`);
1113
+ continue;
1114
+ }
1115
+ if (property.shorthand === true) {
1116
+ entries.push(property.name);
1117
+ continue;
1118
+ }
1119
+ const value = renderWatchSubject(property.value);
1120
+ if (value === null)
1121
+ return null;
1122
+ entries.push(`${property.name}: ${value}`);
1123
+ }
1124
+ return `{${entries.join(", ")}}`;
1125
+ }
1126
+ case "ArrowFunctionExpression": {
1127
+ // An annotated or defaulted parameter would need a type printer this file
1128
+ // has no business owning; the callback shapes a subject carries do not.
1129
+ if (expression.parameters.some((parameter) => parameter.type !== null || parameter.defaultValue !== null))
1130
+ return null;
1131
+ const body = renderWatchSubject(expression.body);
1132
+ if (body === null)
1133
+ return null;
1134
+ const parameters = expression.parameters.map((parameter) => `${parameter.rest ? "..." : ""}${parameter.name}`);
1135
+ const head = expression.parameters.length === 1 && !expression.parameters[0].rest
1136
+ ? parameters[0]
1137
+ : `(${parameters.join(", ")})`;
1138
+ return `${expression.asynchronous ? "async " : ""}${head} => ${body}`;
1139
+ }
1140
+ case "UnaryExpression": {
1141
+ const operand = renderWatchSubjectOperand(expression.operand);
1142
+ if (operand === null)
1143
+ return null;
1144
+ const spaced = expression.operator === "not" || expression.operator === "await";
1145
+ return `${expression.operator}${spaced ? " " : ""}${operand}`;
1146
+ }
1147
+ case "BinaryExpression": {
1148
+ const left = renderWatchSubjectOperand(expression.left);
1149
+ const right = renderWatchSubjectOperand(expression.right);
1150
+ if (left === null || right === null)
1151
+ return null;
1152
+ return groupWatchSubject(`${left} ${expression.operator} ${right}`, expression.parenthesized);
1153
+ }
1154
+ case "ComparisonChainExpression": {
1155
+ const operands = expression.operands.map((operand) => renderWatchSubjectOperand(operand));
1156
+ const first = operands[0];
1157
+ if (first === undefined || first === null || operands.some((operand) => operand === null))
1158
+ return null;
1159
+ const chain = operands.slice(1).reduce((text, operand, index) => `${text} ${expression.operators[index]} ${operand}`, first);
1160
+ return groupWatchSubject(chain, expression.parenthesized);
1161
+ }
1162
+ case "ConditionalExpression": {
1163
+ const condition = renderWatchSubjectOperand(expression.condition);
1164
+ const thenValue = renderWatchSubjectOperand(expression.thenValue);
1165
+ const elseValue = renderWatchSubject(expression.elseValue);
1166
+ if (condition === null || thenValue === null || elseValue === null)
1167
+ return null;
1168
+ return `${condition} ? ${thenValue} : ${elseValue}`;
1169
+ }
1170
+ case "IsExpression": {
1171
+ const value = renderWatchSubjectOperand(expression.value);
1172
+ // Only a plain named type reads back as one word; anything generic or
1173
+ // optional would need a type printer this file has no business owning.
1174
+ if (value === null || expression.type.syntax.kind !== "NamedTypeSyntax")
1175
+ return null;
1176
+ return groupWatchSubject(`${value} ${expression.operator} ${expression.type.syntax.name}`, expression.parenthesized);
1177
+ }
1178
+ case "FStringExpression": {
1179
+ let text = "";
1180
+ for (const part of expression.parts) {
1181
+ if (part.kind === "text") {
1182
+ text += renderWatchTextLiteral(part.value, true);
1183
+ continue;
1184
+ }
1185
+ const value = renderWatchSubject(part.value);
1186
+ if (value === null)
1187
+ return null;
1188
+ text += `{${value}}`;
1189
+ }
1190
+ return `f"${text}"`;
1191
+ }
1192
+ default:
1193
+ return null;
1194
+ }
1195
+ }
1196
+ /**
1197
+ * The same reconstruction in a position where a bare printing would bind
1198
+ * differently than the author's source did — a binary operand, the object of a
1199
+ * member read, the callee of a call. A conditional or an arrow reaches such a
1200
+ * position only through parentheses the author wrote, and neither node records
1201
+ * them, so they are restored here.
1202
+ */
1203
+ function renderWatchSubjectOperand(expression) {
1204
+ const rendered = renderWatchSubject(expression);
1205
+ if (rendered === null)
1206
+ return null;
1207
+ return expression.kind === "ConditionalExpression" || expression.kind === "ArrowFunctionExpression"
1208
+ ? `(${rendered})`
1209
+ : rendered;
1210
+ }
1211
+ /** Restores the parentheses the author wrote, for the three nodes that record them. */
1212
+ function groupWatchSubject(rendered, parenthesized) {
1213
+ return parenthesized === true ? `(${rendered})` : rendered;
1214
+ }
1215
+ /**
1216
+ * D89 A4: what a `map` callback rebuilds, when it returns a newly built record
1217
+ * instead of the row it was handed. Null is the non-trigger answer the ruling
1218
+ * names — a callback that returns its parameter, or anything else that is not a
1219
+ * record literal, builds nothing and moves no identity. A record is reported
1220
+ * even when no field can be named, because it is the rebuilt row's own identity
1221
+ * that the keyed list stops recognising, not any one field it carries.
1222
+ *
1223
+ * A conditional is read through because the React spelling nearly always
1224
+ * carries one, `t.id == id ? {...t, done: true} : t`, and its `else` branch is
1225
+ * the returns-the-original case rather than a second shape. A branch that names
1226
+ * a field is preferred, since a suggestion that names one is the whole product.
1227
+ */
1228
+ function keyedRebuiltRecord(body, row) {
1229
+ if (body.kind === "ObjectExpression") {
1230
+ // `{ id: item.id, done: true }` copies the key across and rewrites `done`.
1231
+ // Naming `id` would send the author to write the one field whose value the
1232
+ // keyed list reads, so a field that only carries the row's own value over
1233
+ // is not the field the message is about.
1234
+ for (const property of body.properties) {
1235
+ if (property.kind !== "ObjectProperty")
1236
+ continue;
1237
+ if (rowFieldPassthrough(property.value, row, property.name))
1238
+ continue;
1239
+ return { field: property.name };
1240
+ }
1241
+ // A pure `{...item}` rewrites no field at all; the copy itself is what
1242
+ // moves the row's identity, so there is no field name for the message.
1243
+ return body.properties.length > 0 ? { field: null } : null;
1244
+ }
1245
+ if (body.kind === "ConditionalExpression") {
1246
+ const thenSide = keyedRebuiltRecord(body.thenValue, row);
1247
+ const elseSide = keyedRebuiltRecord(body.elseValue, row);
1248
+ if (thenSide?.field)
1249
+ return thenSide;
1250
+ if (elseSide?.field)
1251
+ return elseSide;
1252
+ return thenSide ?? elseSide;
1253
+ }
1254
+ return null;
1255
+ }
1256
+ /**
1257
+ * The reactive declarations a `def` body may not hold when the `def` answers
1258
+ * `WebNode`. `watch`, `resource` and `action` are already refused outside a
1259
+ * module or component scope (VEL3010/VEL3012/VEL3013); they stay listed because
1260
+ * the defect is the declaration, not which of them the author reached for.
1261
+ */
1262
+ const WEB_REACTIVE_DECLARATION_LABELS = new Map([
1263
+ ["ExtensionStatement:web:state", "'state'"],
1264
+ ["ExtensionStatement:web:computed", "'computed'"],
1265
+ ["ExtensionStatement:web:resource", "'resource'"],
1266
+ ["ExtensionStatement:web:watch", "'watch'"],
1267
+ ["ExtensionStatement:web:mounted", "'@mounted'"],
1268
+ ["ExtensionStatement:web:cleanup", "'@cleanup'"],
1269
+ ]);
1270
+ /**
1271
+ * The first reactive declaration a body holds, in source order. A nested `def`
1272
+ * or `component` owns its own declarations, so the walk stops at one.
1273
+ */
1274
+ function firstReactiveDeclaration(body) {
1275
+ let found = null;
1276
+ const pending = [...body];
1277
+ while (pending.length > 0) {
1278
+ const value = pending.pop();
1279
+ if (Array.isArray(value)) {
1280
+ for (const entry of value)
1281
+ pending.push(entry);
1282
+ continue;
1283
+ }
1284
+ if (value === null || typeof value !== "object")
1285
+ continue;
1286
+ const node = value;
1287
+ const kind = typeof node.kind === "string" ? node.kind : null;
1288
+ if (kind === "FunctionDeclaration" || kind === "ExtensionStatement:web:component")
1289
+ continue;
1290
+ const label = kind === null ? undefined : WEB_REACTIVE_DECLARATION_LABELS.get(kind);
1291
+ if (label !== undefined) {
1292
+ const span = node.span;
1293
+ if (found === null || span.start < found.span.start)
1294
+ found = { label, span };
1295
+ continue;
1296
+ }
1297
+ for (const key of Object.keys(node))
1298
+ pending.push(node[key]);
1299
+ }
1300
+ return found;
1301
+ }
1302
+ /**
1303
+ * Whether a type answers markup. `WebNode` is the value, and `WebNode?` and
1304
+ * `List<WebNode>` are the two shapes markup legitimately travels in — a helper
1305
+ * that answers "this row, or nothing" and one that answers a row per item.
1306
+ * Reading only the bare annotation closed the spelling instead of the sink:
1307
+ * the identical body under `-> WebNode?` reached the same defect unreported.
1308
+ */
1309
+ function carriesWebNode(type) {
1310
+ if (isWebNodeType(type))
1311
+ return true;
1312
+ if (type.kind === "optional")
1313
+ return carriesWebNode(type.inner);
1314
+ if (type.kind === "list")
1315
+ return carriesWebNode(type.element);
1316
+ return false;
1317
+ }
1318
+ /**
1319
+ * Whether a body's own `return`s carry JSX. A return type is optional on a
1320
+ * `def`, and an omitted one left the same defective helper unreported, so the
1321
+ * markup the body returns answers for the annotation that was never written.
1322
+ * A nested `def` or `component` owns its own returns, exactly as it owns its
1323
+ * own declarations.
1324
+ */
1325
+ function bodyReturnsJsx(body) {
1326
+ const pending = [...body];
1327
+ while (pending.length > 0) {
1328
+ const value = pending.pop();
1329
+ if (Array.isArray(value)) {
1330
+ for (const entry of value)
1331
+ pending.push(entry);
1332
+ continue;
1333
+ }
1334
+ if (value === null || typeof value !== "object")
1335
+ continue;
1336
+ const node = value;
1337
+ const kind = typeof node.kind === "string" ? node.kind : null;
1338
+ if (kind === "FunctionDeclaration" || kind === "ExtensionStatement:web:component")
1339
+ continue;
1340
+ if (kind === "ReturnStatement") {
1341
+ if (subtreeHoldsJsx(node.value))
1342
+ return true;
1343
+ continue;
1344
+ }
1345
+ for (const key of Object.keys(node))
1346
+ pending.push(node[key]);
1347
+ }
1348
+ return false;
1349
+ }
1350
+ /**
1351
+ * A returned expression that builds markup anywhere inside it — bare, inside a
1352
+ * List, behind a condition, or built by the callback of a `.map(...)` that
1353
+ * answers a row per item. The arrow is walked rather than skipped for the same
1354
+ * reason the optional and List annotations are unwrapped: `rows()` returning
1355
+ * `items.map(item => <li>…</li>)` is a markup helper by every measure except
1356
+ * the one spelling that was being read.
1357
+ */
1358
+ function subtreeHoldsJsx(value) {
1359
+ const pending = [value];
1360
+ while (pending.length > 0) {
1361
+ const entry = pending.pop();
1362
+ if (Array.isArray(entry)) {
1363
+ for (const item of entry)
1364
+ pending.push(item);
1365
+ continue;
1366
+ }
1367
+ if (entry === null || typeof entry !== "object")
1368
+ continue;
1369
+ const node = entry;
1370
+ if (node.kind === "ExtensionExpression:web:jsx")
1371
+ return true;
1372
+ for (const key of Object.keys(node))
1373
+ pending.push(node[key]);
1374
+ }
1375
+ return false;
1376
+ }
1377
+ /** The component spelling of a helper's name: components render as a PascalCase tag. */
1378
+ function componentSpelling(name) {
1379
+ return name.length === 0 ? name : `${name[0].toUpperCase()}${name.slice(1)}`;
1380
+ }
1381
+ /** `{ id: item.id }` in a callback over `item`: the field carries its own value over unchanged. */
1382
+ function rowFieldPassthrough(value, row, name) {
1383
+ return value.kind === "MemberExpression" && value.property === name
1384
+ && value.object.kind === "IdentifierExpression" && value.object.name === row;
1385
+ }
747
1386
  export class VelarWebAnalyzer extends Analyzer {
748
1387
  componentStates = null;
749
1388
  mountedDepth = 0;
750
1389
  cleanupDepth = 0;
751
1390
  /** D51 (audit 12): a component `watch` body runs on a change and ends, exactly as a module `watch` body does. */
752
1391
  watchBodyDepth = 0;
1392
+ /** D89 A4: the binding identity of every list a keyed `.map(...)` interpolation renders. */
1393
+ keyedListSources = new Set();
1394
+ /** D89 A4: every `list = list.map(item => {…})` rewrite of this module, in source order. */
1395
+ keyedListRebuilds = [];
753
1396
  synchronousReactiveDepth = 0;
754
1397
  jsxDepth = 0;
755
1398
  resources;
@@ -764,6 +1407,7 @@ export class VelarWebAnalyzer extends Analyzer {
764
1407
  honoredJsxKeys = new Set();
765
1408
  reportedJsxKeys = new Set();
766
1409
  lookBuilderNames = new Map();
1410
+ lookDeclarations = new Map();
767
1411
  lookLiteralDepth = 0;
768
1412
  /**
769
1413
  * D71 rule 182: the declaration spans of every `computed` binding in scope.
@@ -775,7 +1419,7 @@ export class VelarWebAnalyzer extends Analyzer {
775
1419
  importedComputedNames;
776
1420
  /** The resolved spans of those imports, so a local shadow of the name is not one. */
777
1421
  importedComputedSpans = new Set();
778
- /** D71 migration state: retired `const x = computed(...)` sites, their reads, and every other `computed` reference. */
1422
+ /** D71 migration state: `const x = computed(...)` sites, their reads, and every other reference to the name. */
779
1423
  retiredAccessorDeclarations = new Map();
780
1424
  retiredAccessorReads = new Map();
781
1425
  retiredComputedReferences = new Map();
@@ -797,13 +1441,17 @@ export class VelarWebAnalyzer extends Analyzer {
797
1441
  analyze(program) {
798
1442
  this.lookStaticValues = collectLookStaticValues(program, this.importedLookStaticValues);
799
1443
  this.lookBuilderNames = collectLookBuilderNames(program);
1444
+ this.lookDeclarations = collectLookDeclarations(program);
800
1445
  for (const name of collectDerivedReactiveNames(program))
801
1446
  this.derivedReactiveNames.add(name);
802
1447
  this.reportBrowserTestImports(program);
803
1448
  this.rejectWebOwnedTypeNames(program);
1449
+ this.keyedListSources.clear();
1450
+ this.keyedListRebuilds.length = 0;
804
1451
  super.analyze(program);
805
1452
  this.reportStaticJsxKeys();
806
1453
  this.reportRetiredComputedFunction();
1454
+ this.adviseKeyedListRebuilds();
807
1455
  return this.diagnostics;
808
1456
  }
809
1457
  /**
@@ -912,8 +1560,9 @@ export class VelarWebAnalyzer extends Analyzer {
912
1560
  return true;
913
1561
  if (statement.kind === "VariableDeclaration") {
914
1562
  this.recordRetiredAccessorDeclaration(statement);
915
- this.reportExportedCachedContract(statement);
916
1563
  }
1564
+ if (statement.kind === "FunctionDeclaration")
1565
+ this.rejectStatefulWebNodeFunction(statement);
917
1566
  if (!isWebStatement(statement))
918
1567
  return false;
919
1568
  switch (statement.kind) {
@@ -969,7 +1618,7 @@ export class VelarWebAnalyzer extends Analyzer {
969
1618
  // the body only runs on a later change, so its reads are deferred
970
1619
  // for the module-initialization-cycle classification.
971
1620
  const watched = this.inferExpression(statement.expression);
972
- this.rejectFrozenWatchSubject(statement.expression, watched);
1621
+ this.rejectFrozenWatchSubject(statement.expression, watched, statement.currentName, statement.previousName);
973
1622
  this.enterScope();
974
1623
  if (statement.currentName)
975
1624
  this.declareBinding(statement.currentName, false, watched, statement.span);
@@ -1019,6 +1668,7 @@ export class VelarWebAnalyzer extends Analyzer {
1019
1668
  }
1020
1669
  analyzeStatement(statement) {
1021
1670
  const readonlyProp = this.directReadonlyPropMutation(statement);
1671
+ this.recordKeyedListRebuild(statement);
1022
1672
  const firstDiagnostic = this.diagnostics.length;
1023
1673
  super.analyzeStatement(statement);
1024
1674
  if (!readonlyProp)
@@ -1251,11 +1901,12 @@ export class VelarWebAnalyzer extends Analyzer {
1251
1901
  // unknown one: the author gets its migration and nothing else, and the
1252
1902
  // call around it still type-checks against the signature it always had.
1253
1903
  if (retired)
1254
- return CACHED_INTRINSIC_TYPE;
1904
+ return RETIRED_ACCESSOR_TYPE;
1255
1905
  }
1256
1906
  const result = super.inferExpression(expression, contextualType);
1257
- if (expression.kind === "CallExpression")
1907
+ if (expression.kind === "CallExpression") {
1258
1908
  this.checkLookBuilderCall(expression);
1909
+ }
1259
1910
  return result;
1260
1911
  }
1261
1912
  // A name refers to writable reactive state only when ordinary lexical lookup
@@ -1263,6 +1914,38 @@ export class VelarWebAnalyzer extends Analyzer {
1263
1914
  writableStateName(name) {
1264
1915
  return this.reactiveBindingKind(name) === "state";
1265
1916
  }
1917
+ /**
1918
+ * The audit's seventh root cause: a `def` that declares reactive state and
1919
+ * answers `WebNode` is a component wearing a function's clothes, and calling
1920
+ * it bypasses exactly what the charter already refuses `View(...)` for. Two
1921
+ * things follow from the call, both reproduced: every call runs the `state`
1922
+ * declaration again, so the value resets on every re-render; and the
1923
+ * observers the returned markup registers bind to whatever scope the call
1924
+ * site was building — at module scope the global one, which is never
1925
+ * destroyed, so they are never cleaned up.
1926
+ *
1927
+ * Only DECLARATION is refused. A `def -> WebNode` that merely reads state or a
1928
+ * prop is a legitimate markup helper — examples/app has two — and a `def`
1929
+ * nested inside a component binds its observers to that component's scope, so
1930
+ * nothing about reading is defective.
1931
+ *
1932
+ * What answers "this `def` returns markup" is the sink, not one spelling of
1933
+ * it: `-> WebNode?` and `-> List<WebNode>` are the shapes markup travels in,
1934
+ * and a `def` may carry no return type at all — all three reached the same
1935
+ * defect with the same body while only the bare annotation was read.
1936
+ */
1937
+ rejectStatefulWebNodeFunction(statement) {
1938
+ const answersMarkup = statement.returnType
1939
+ ? carriesWebNode(this.resolveAnnotation(statement.returnType))
1940
+ : bodyReturnsJsx(statement.body);
1941
+ if (!answersMarkup)
1942
+ return;
1943
+ const declaration = firstReactiveDeclaration(statement.body);
1944
+ if (!declaration)
1945
+ return;
1946
+ const component = componentSpelling(statement.name);
1947
+ this.diagnostics.push(diagnostic("VEL5074", `'${statement.name}' declares ${declaration.label} and returns WebNode, so calling it bypasses JSX ownership, prop cells, and lifecycle: every call declares the value again, so it resets on each render, and the observers its markup registers belong to whatever scope the call site was building rather than to this value. Write it as a component — 'component ${component}(...)' rendered as '<${component} />'; a 'def' that returns WebNode is a markup helper and may only read.`, declaration.span));
1948
+ }
1266
1949
  /**
1267
1950
  * D71 rule 182: a declared derived value is read bare, exactly like state.
1268
1951
  * Calling one is the habit the retired `computed(...)` accessor taught, and it
@@ -1296,23 +1979,110 @@ export class VelarWebAnalyzer extends Analyzer {
1296
1979
  /**
1297
1980
  * D69 rule 178: a `watch` body that can never run is a block of statements
1298
1981
  * the compile silently drops — the same defect a bare `5` is already rejected
1299
- * for (VEL4030), reached from a position the rule could not see. The subject
1300
- * is refused only where the compile can *prove* nothing behind it moves, so a
1301
- * call whose reactivity lives inside another module is left alone: a rule that
1302
- * rejected those would be worse than the hole it closes.
1982
+ * for (VEL4030), reached from a position the rule could not see.
1983
+ *
1984
+ * D90 R15(a) adds the third refusal and fixes the order the three are asked
1985
+ * in. A frozen subject is answered before the shape rule so that one shape
1986
+ * never draws two messages: a subject built only from frozen parts has no
1987
+ * reactive source at all, and telling its author to declare a `computed`
1988
+ * would only buy him a dead one. What survives both is either a path — the
1989
+ * name of a reactive binding, or a member/index read out of one — or a
1990
+ * computation, and a computation has a spelling of its own.
1303
1991
  *
1304
- * The two refusals are separate because their causes are: a reader that was
1305
- * not called has one correct spelling to name, and a frozen value has none.
1992
+ * The three refusals are separate because their causes are: a reader that was
1993
+ * not called names a value that never moves, a frozen value has no reactive
1994
+ * source behind it at all, and a computed subject has one but hides which.
1306
1995
  */
1307
- rejectFrozenWatchSubject(expression, watched) {
1996
+ rejectFrozenWatchSubject(expression, watched, currentName, previousName) {
1308
1997
  const name = expression.kind === "IdentifierExpression" ? expression.name : null;
1309
1998
  if (name !== null && this.reactiveBindingKind(name) === null && this.zeroArgumentReader(watched)) {
1310
- this.diagnostics.push(diagnostic("VEL5064", `'${name}' is the reader itself, so watching it watches a value that never changes; write 'watch ${name}():' to watch what it reads`, expression.span));
1999
+ this.diagnostics.push(diagnostic("VEL5064", `'${name}' is the reader itself, so watching it watches a value that never changes; declare the derived value — 'computed name = ${name}()' then 'watch name:'`, expression.span));
2000
+ return;
2001
+ }
2002
+ if (this.frozenWatchSubject(expression)) {
2003
+ this.diagnostics.push(diagnostic("VEL5064", `This watch subject never changes, so its body can never run${name === null ? "" : ` — '${name}' is not a reactive source`}; watch a 'state', a 'computed', a prop, or a resource field, or move these statements to where they should run`, expression.span));
1311
2004
  return;
1312
2005
  }
1313
- if (!this.frozenWatchSubject(expression))
2006
+ if (watchSubjectPath(expression))
1314
2007
  return;
1315
- this.diagnostics.push(diagnostic("VEL5064", `This watch subject never changes, so its body can never run${name === null ? "" : ` '${name}' is not a reactive source`}; watch a 'state', a 'computed', a prop, or a resource field, or move these statements to where they should run`, expression.span));
2008
+ // D69's own shape, `watch total()`, is a called `computed`, and VEL5063 has
2009
+ // already named it with the one-character edit that makes this subject
2010
+ // legal. Stacking the shape rule on top would report one mistake twice and
2011
+ // would hand the author 'computed value = total()' — a line that reports
2012
+ // VEL5063 in its turn. The same reason the frozen rule is asked first.
2013
+ if (this.diagnostics.some((item) => item.code === "VEL5063"
2014
+ && item.span.start === expression.span.start && item.span.end === expression.span.end))
2015
+ return;
2016
+ const derived = currentName ?? "value";
2017
+ const watchLine = currentName === null
2018
+ ? `watch ${derived}:`
2019
+ : `watch ${derived} as current${previousName === null ? "" : `, ${previousName}`}:`;
2020
+ const rendered = renderWatchSubject(expression);
2021
+ this.diagnostics.push(diagnostic("VEL5071", rendered === null
2022
+ ? `A watch subject names what to watch, not what to compute. Declare the value — 'computed ${derived} = ...' — then '${watchLine}'`
2023
+ : `A watch subject names what to watch, not what to compute: '${rendered}' computes a value. Declare it — 'computed ${derived} = ${rendered}' — then '${watchLine}'`, expression.span));
2024
+ }
2025
+ /**
2026
+ * D89 A4: records `list = list.map(item => {…})`, React's immutable update,
2027
+ * where the callback builds a new record rather than changing a field.
2028
+ *
2029
+ * D90 R2 stands — `__velarKeyed` compares identity and that does not move,
2030
+ * and the framework does not accommodate the idiom. This channel is not the
2031
+ * framework taking responsibility; it is the compiler telling the author that
2032
+ * the row he is typing into is about to be destroyed. Nothing is reported
2033
+ * yet: the advisory is owed only if the rewritten list is what a keyed list
2034
+ * renders, and the render usually sits below the update.
2035
+ */
2036
+ recordKeyedListRebuild(statement) {
2037
+ if (statement.kind !== "AssignmentStatement" || statement.operator !== "=")
2038
+ return;
2039
+ if (statement.target.kind !== "IdentifierExpression")
2040
+ return;
2041
+ const value = statement.value;
2042
+ if (value.kind !== "CallExpression" || value.callee.kind !== "MemberExpression" || value.callee.property !== "map")
2043
+ return;
2044
+ // The map has to be over the list being replaced; `rows = source.map(...)`
2045
+ // builds a new list, and a new list has no identity to preserve.
2046
+ if (value.callee.object.kind !== "IdentifierExpression" || value.callee.object.name !== statement.target.name)
2047
+ return;
2048
+ const callback = value.arguments[0];
2049
+ if (!callback || callback.kind !== "ArrowFunctionExpression")
2050
+ return;
2051
+ const [row] = callback.parameters;
2052
+ if (!row || callback.parameters.length !== 1)
2053
+ return;
2054
+ const rebuilt = keyedRebuiltRecord(callback.body, row.name);
2055
+ if (!rebuilt)
2056
+ return;
2057
+ const binding = this.lookup(statement.target.name);
2058
+ if (!binding)
2059
+ return;
2060
+ this.keyedListRebuilds.push({
2061
+ source: spanIdentity(binding.span),
2062
+ name: statement.target.name,
2063
+ field: rebuilt.field,
2064
+ span: statement.span,
2065
+ });
2066
+ }
2067
+ /**
2068
+ * D89 A4: raises the advisory for the rewrites whose list a keyed position
2069
+ * really renders. The advisory channel cannot reach `this.diagnostics`, so
2070
+ * nothing here fails a build, changes an emitted byte, or moves a semantic
2071
+ * rule; `// velar-allow A4: <reason>` suppresses it where `map` plus a record
2072
+ * literal is the only spelling, which a `readonly` list or one API response
2073
+ * makes it.
2074
+ */
2075
+ adviseKeyedListRebuilds() {
2076
+ for (const rebuild of this.keyedListRebuilds) {
2077
+ if (!this.keyedListSources.has(rebuild.source))
2078
+ continue;
2079
+ // The keys do not move; the rows do. `__velarKeyed` finds the entry under
2080
+ // the same key and then drops it because the row it holds is no longer
2081
+ // the same value, so a message blaming the key sends an author to check
2082
+ // `id`, find it unchanged, and conclude the advisory is wrong.
2083
+ const field = rebuild.field ?? "<field>";
2084
+ this.advise("A4", `This rebuilds every row of '${rebuild.name}', so every row is a new value and the keyed list that renders '${rebuild.name}' no longer recognises any of them: it destroys and rebuilds all of its children — an input being typed into loses focus. Change the field in place instead: '${rebuild.name}[index].${field} = ...'`, rebuild.span);
2085
+ }
1316
2086
  }
1317
2087
  /** A value that is read by calling it and takes no arguments to do so. */
1318
2088
  zeroArgumentReader(type) {
@@ -1380,16 +2150,16 @@ export class VelarWebAnalyzer extends Analyzer {
1380
2150
  return true;
1381
2151
  }
1382
2152
  /**
1383
- * D71 rule 183: `computed(...)` the function is retired in favour of
1384
- * `cached(...)`, and `const x = computed(() => E)` is retired in favour of the
1385
- * declaration. The declaration is recorded before the core walks the module so
1386
- * its reads can be matched against it — the rewrite that removes the call
2153
+ * D71 rule 183: `computed(...)` the function the shape Vue and the signals
2154
+ * libraries teach is not how a derived value is written here; the `computed`
2155
+ * declaration is. The declaration is recorded before the core walks the module
2156
+ * so its reads can be matched against it — the rewrite that removes the call
1387
2157
  * parentheses is only offered when every read is a plain `x()`.
1388
2158
  */
1389
2159
  recordRetiredAccessorDeclaration(statement) {
1390
2160
  const initializer = statement.initializer;
1391
2161
  if (initializer.kind !== "CallExpression" || initializer.callee.kind !== "IdentifierExpression"
1392
- || initializer.callee.name !== "computed" || this.lookup("computed") !== null)
2162
+ || !isRetiredAccessorName(initializer.callee.name) || this.lookup(initializer.callee.name) !== null)
1393
2163
  return;
1394
2164
  if (statement.binding !== "const" || statement.pattern.kind !== "NameBindingPattern")
1395
2165
  return;
@@ -1398,25 +2168,26 @@ export class VelarWebAnalyzer extends Analyzer {
1398
2168
  // Only the `() => E` shape has a body that becomes the declaration's
1399
2169
  // initializer verbatim. Every other argument — a named function, a partial
1400
2170
  // application — would need the rewriter to invent an expression, so it is
1401
- // left to the author with the rename as its only mechanical answer.
2171
+ // left to the author with the call spelled out as its only mechanical answer.
1402
2172
  const body = read?.kind === "ArrowFunctionExpression" && !read.asynchronous && read.parameters.length === 0
1403
2173
  ? read.body : null;
1404
2174
  this.retiredAccessorDeclarations.set(spanIdentity(statement.pattern.span), {
1405
2175
  name: statement.pattern.name,
1406
2176
  exported: statement.exported,
2177
+ readName: read?.kind === "IdentifierExpression" ? read.name : null,
1407
2178
  declarationSpan: statement.span,
1408
2179
  callSpan: initializer.span,
1409
2180
  bodySpan: body ? body.span : null,
1410
2181
  });
1411
2182
  this.migratedComputedCallees.add(spanIdentity(initializer.callee.span));
1412
2183
  }
1413
- /** Returns true when the name is the retired `computed` global this pass owns. */
2184
+ /** Returns true when the name is one of the retired accessor globals this pass owns. */
1414
2185
  recordRetiredAccessorRead(expression) {
1415
- if (expression.name === "computed") {
1416
- if (this.lookup("computed") !== null)
2186
+ if (isRetiredAccessorName(expression.name)) {
2187
+ if (this.lookup(expression.name) !== null)
1417
2188
  return false;
1418
2189
  if (!this.migratedComputedCallees.has(spanIdentity(expression.span))) {
1419
- this.retiredComputedReferences.set(spanIdentity(expression.span), expression.span);
2190
+ this.retiredComputedReferences.set(spanIdentity(expression.span), { name: expression.name, span: expression.span });
1420
2191
  }
1421
2192
  return true;
1422
2193
  }
@@ -1434,11 +2205,12 @@ export class VelarWebAnalyzer extends Analyzer {
1434
2205
  return false;
1435
2206
  }
1436
2207
  /**
1437
- * D71 migration: one message per retired site, and a mechanical rewrite only
1438
- * where the compile can prove the rewrite. Where the accessor is used as a
1439
- * value the choice between `computed x = E` and `cached(...)` turns on whether
1440
- * the caller wants the cache, which is the author's decision so that site
1441
- * gets the message and no edit.
2208
+ * D71 migration: one message per site, and a mechanical rewrite only where
2209
+ * the compile can prove the rewrite. Where the
2210
+ * accessor is used as a value there is no second spelling left to offer, so
2211
+ * that site is told to declare the value and write an ordinary `def` where a
2212
+ * callable is what the caller wants — and it gets no edit, because moving a
2213
+ * reader out of a value position is the author's decision.
1442
2214
  */
1443
2215
  reportRetiredComputedFunction() {
1444
2216
  for (const [key, accessor] of this.retiredAccessorDeclarations) {
@@ -1453,8 +2225,8 @@ export class VelarWebAnalyzer extends Analyzer {
1453
2225
  ]
1454
2226
  : null;
1455
2227
  const alternative = accessor.bodySpan === null
1456
- ? ` Where the argument is a function rather than an expression, write the call — 'computed ${accessor.name} = read()' or keep a passable cached reader with 'cached(...)'`
1457
- : ` Where the reader itself is passed on rather than read here, keep it a value with 'cached(...)'`;
2228
+ ? ` Where the argument is a function rather than an expression, write the call — 'computed ${accessor.name} = ${accessor.readName ?? "read"}()'`
2229
+ : ` Where the reader itself is passed on rather than read here, declare the value 'computed ${accessor.name} = ...' — and write an ordinary 'def' where a callable is required`;
1458
2230
  this.diagnostics.push({
1459
2231
  code: "VEL5055",
1460
2232
  message: `A derived value is declared, not called: write 'computed ${accessor.name} = ...' and read '${accessor.name}' bare.${rewritable ? "" : alternative}`,
@@ -1462,29 +2234,13 @@ export class VelarWebAnalyzer extends Analyzer {
1462
2234
  ...(edits ? { fix: { title: `Declare '${accessor.name}' with computed`, edits } } : {}),
1463
2235
  });
1464
2236
  }
1465
- for (const span of this.retiredComputedReferences.values()) {
1466
- this.diagnostics.push({
1467
- code: "VEL5055",
1468
- message: "'computed' is the keyword that declares a derived value — 'computed name = expression'. The function that returns a cached reader is now 'cached'",
1469
- span,
1470
- fix: { title: "Rename computed to cached", edits: [{ span, text: "cached" }] },
1471
- });
2237
+ // There is no `fix`: the rewrite is a declaration, not a rename, and the
2238
+ // declaration form is offered above where the compile can see the whole
2239
+ // shape.
2240
+ for (const reference of this.retiredComputedReferences.values()) {
2241
+ this.diagnostics.push(diagnostic("VEL5055", "'computed' declares a derived value — 'computed name = expression'. There is no function form, and 'computed' already caches", reference.span));
1472
2242
  }
1473
2243
  }
1474
- /**
1475
- * D71 rule 183 keeps the export-boundary contract the retired spelling had:
1476
- * an exported cached reader has no inferable public type, so the annotation
1477
- * is required at the boundary rather than discovered by the importer.
1478
- */
1479
- reportExportedCachedContract(statement) {
1480
- if (!statement.exported || statement.type || statement.pattern.kind !== "NameBindingPattern")
1481
- return;
1482
- const initializer = statement.initializer;
1483
- if (initializer.kind !== "CallExpression" || initializer.callee.kind !== "IdentifierExpression"
1484
- || initializer.callee.name !== "cached" || this.lookup("cached") !== null)
1485
- return;
1486
- this.diagnostics.push(diagnostic("VEL4025", `Exported cached readers need an explicit contract at the export boundary; write 'export const ${statement.pattern.name}: () -> T = cached(...)', or declare the derived value itself with 'export computed ${statement.pattern.name} = ...'`, statement.span));
1487
- }
1488
2244
  extensionFieldsOf(name) {
1489
2245
  return webTypeFields(name);
1490
2246
  }
@@ -1697,7 +2453,7 @@ export class VelarWebAnalyzer extends Analyzer {
1697
2453
  this.flowFrameDepth += 1;
1698
2454
  this.synchronousReactiveDepth += 1;
1699
2455
  const watched = this.inferExpression(item.expression);
1700
- this.rejectFrozenWatchSubject(item.expression, watched);
2456
+ this.rejectFrozenWatchSubject(item.expression, watched, item.currentName, item.previousName);
1701
2457
  this.enterScope();
1702
2458
  if (item.currentName)
1703
2459
  this.declareBinding(item.currentName, false, watched, item.span);
@@ -1862,9 +2618,10 @@ export class VelarWebAnalyzer extends Analyzer {
1862
2618
  if (inheritedTerms * Math.max(thenTerms, elseTerms) > LOOK_CONDITION_TERM_LIMIT) {
1863
2619
  this.diagnostics.push(diagnostic("VEL5045", `A Look condition may expand to at most ${LOOK_CONDITION_TERM_LIMIT} selector/runtime terms; split this visual decision into ordinary values`, entry.condition.span));
1864
2620
  }
1865
- const signature = lookConditionSignature(entry.condition);
1866
- this.analyzeLookEntries(entry.thenEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * thenTerms), `${scopeKey}&${signature}`);
1867
- this.analyzeLookEntries(entry.elseEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * elseTerms), `${scopeKey}&!${signature}`);
2621
+ const thenKey = lookConditionKey(entry.condition, false, this.lookStaticValues);
2622
+ const elseKey = lookConditionKey(entry.condition, true, this.lookStaticValues);
2623
+ this.analyzeLookEntries(entry.thenEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * thenTerms), `${scopeKey}&${thenKey}`);
2624
+ this.analyzeLookEntries(entry.elseEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * elseTerms), `${scopeKey}&${elseKey}`);
1868
2625
  continue;
1869
2626
  }
1870
2627
  if (entry.kind === "LookTarget") {
@@ -1975,13 +2732,25 @@ export class VelarWebAnalyzer extends Analyzer {
1975
2732
  return;
1976
2733
  }
1977
2734
  const ranges = LOOK_BUILDER_NUMERIC_RANGES.get(builder);
2735
+ // A named argument fills the same slot its positional spelling does, so the
2736
+ // position comes from the builder's own signature — the one table the
2737
+ // module interface, the named-argument arity check, and the `keyframes:`
2738
+ // lowering already derive from. Reading `-1` here instead put every check
2739
+ // in this loop out of reach of the named spelling, not only the range
2740
+ // table: `rgba(0, 0, 0, alpha=2)` compiled clean while `rgba(0, 0, 0, 2)`
2741
+ // was refused.
2742
+ const parameters = LOOK_BUILDER_SIGNATURES.get(builder)?.parameters;
1978
2743
  for (const [index, argument] of expression.arguments.entries()) {
1979
2744
  const named = expression.argumentNames?.[index] ?? null;
1980
- const position = named ? -1 : index;
2745
+ const position = named === null ? index : parameters?.indexOf(named) ?? -1;
1981
2746
  const range = position >= 0 ? ranges?.[position] : undefined;
1982
- const literal = argument.kind === "LiteralExpression" && typeof argument.value === "number" ? argument.value
1983
- : argument.kind === "UnaryExpression" && argument.operator === "-" && argument.operand.kind === "LiteralExpression" && typeof argument.operand.value === "number"
1984
- ? -argument.operand.value : null;
2747
+ // LOK-U8 completed: a `keyframes:` stop lowers its builder calls at
2748
+ // compile time, so no call survives to run the runtime guard. Reading the
2749
+ // folded value rather than the literal node is what makes the promise
2750
+ // "a computed argument keeps the same check" true where the run time the
2751
+ // charter names does not exist.
2752
+ const folded = evaluateLookStaticExpression(argument, this.lookStaticValues);
2753
+ const literal = folded?.kind === "number" ? folded.value : null;
1985
2754
  if (range && literal !== null && (literal < range[1] || literal > range[2])) {
1986
2755
  this.diagnostics.push(diagnostic("VEL5042", `${range[0]} must be from ${range[1]} through ${range[2]}; ${builder} received ${literal}`, argument.span));
1987
2756
  }
@@ -1995,6 +2764,14 @@ export class VelarWebAnalyzer extends Analyzer {
1995
2764
  && !LOOK_BORDER_STYLE_NAMES.has(argument.value)) {
1996
2765
  this.diagnostics.push(diagnostic("VEL5042", `Border style '${argument.value}' is not a CSS border style; use one of ${[...LOOK_BORDER_STYLE_NAMES].join(", ")}`, argument.span));
1997
2766
  }
2767
+ // D60 rule 150 gave `transitionProperty` a real vocabulary and the
2768
+ // charter says the builder takes the same one. It took none, so the
2769
+ // longhand's refusal taught the camelCase spelling the builder accepted
2770
+ // and the browser discarded. Routing the argument through the property's
2771
+ // own checker keeps one vocabulary and one message.
2772
+ if (builder === "transition" && position === 0) {
2773
+ this.validateLookStringVocabulary("transitionProperty", argument, "The transition builder's property argument");
2774
+ }
1998
2775
  }
1999
2776
  if (builder === "tracks" && expression.arguments.length > 1024) {
2000
2777
  this.diagnostics.push(diagnostic("VEL5042", "tracks cannot contain more than 1024 values", expression.span));
@@ -2028,7 +2805,7 @@ export class VelarWebAnalyzer extends Analyzer {
2028
2805
  this.diagnostics.push(diagnostic("VEL5060", "Animation count must be a positive integer no greater than 1000000", count.span));
2029
2806
  }
2030
2807
  if (count && loop) {
2031
- this.diagnostics.push(diagnostic("VEL5060", "animate accepts either count or loop, not both; use loop=true for an infinite animation", expression.span));
2808
+ this.diagnostics.push(diagnostic("VEL5060", "animate accepts either count or loop, not both: count names the number of runs, and loop=true replaces that count with an unbounded one", expression.span));
2032
2809
  }
2033
2810
  this.checkAnimationKeyword(easing, "easing", LOOK_ANIMATION_EASINGS);
2034
2811
  this.checkAnimationKeyword(direction, "direction", LOOK_ANIMATION_DIRECTIONS);
@@ -2061,7 +2838,7 @@ export class VelarWebAnalyzer extends Analyzer {
2061
2838
  const actual = this.inferExpression(entry.value, expected);
2062
2839
  this.reportKeyframeSnapshotReads(entry.value);
2063
2840
  if (keyframeCssValue(entry.value, this.lookStaticValues) === null) {
2064
- this.diagnostics.push(diagnostic("VEL5060", "A keyframe value must resolve to static CSS from literals, unit values, arithmetic, or velar/look builders", entry.value.span));
2841
+ this.diagnostics.push(diagnostic("VEL5060", "A keyframe value must resolve to static CSS from literals, unit values, arithmetic, velar/look builders, or const bindings — local or imported — that hold any of those, and the text it resolves to must read as one declaration value: no ';', '{', '}', or '@' outside a string, with parentheses, strings, and comments all closed", entry.value.span));
2065
2842
  }
2066
2843
  if (mentionsLookUnitType(expected) && lookLiteralZero(entry.value))
2067
2844
  continue;
@@ -2144,11 +2921,18 @@ export class VelarWebAnalyzer extends Analyzer {
2144
2921
  return false;
2145
2922
  return true;
2146
2923
  }
2147
- validateLookStringVocabulary(name, value) {
2924
+ /**
2925
+ * `subject` names the position in the message. It is the property itself
2926
+ * everywhere except the `transition(...)` builder, whose first argument takes
2927
+ * `transitionProperty`'s vocabulary but is not written as that property — a
2928
+ * refusal that named the longhand there would name a spelling the author
2929
+ * never wrote.
2930
+ */
2931
+ validateLookStringVocabulary(name, value, subject = `Look property '${name}'`) {
2148
2932
  const kind = LOOK_PROPERTY_VALUE_KINDS.get(name);
2149
2933
  if (!kind || kind === "text" || kind === "filter" || kind === "transform" || kind === "animation")
2150
2934
  return true;
2151
- const values = literalLookStrings(value);
2935
+ const values = literalStringValues(value) ?? this.foldedLookKeyword(value);
2152
2936
  if (values === null)
2153
2937
  return true;
2154
2938
  for (const text of values) {
@@ -2197,7 +2981,7 @@ export class VelarWebAnalyzer extends Analyzer {
2197
2981
  // D65 rule 169: a property whose CSS value space no set can hold says
2198
2982
  // what it left out, so the boundary is legible where it is met.
2199
2983
  const partial = expected.named ? undefined : LOOK_PARTIAL_KEYWORD_PROPERTIES.get(name);
2200
- this.diagnostics.push(diagnostic("VEL5038", `Look property '${name}' does not accept '${normalized}'; ${expected.text}${partial
2984
+ this.diagnostics.push(diagnostic("VEL5038", `${subject} does not accept '${normalized}'; ${expected.text}${partial
2201
2985
  ? `. ${partial}; use a module-level 'import css unsafe "./styles.css" before look' when that boundary is intentional`
2202
2986
  : ""}`, value.span));
2203
2987
  return false;
@@ -2205,6 +2989,22 @@ export class VelarWebAnalyzer extends Analyzer {
2205
2989
  }
2206
2990
  return true;
2207
2991
  }
2992
+ /**
2993
+ * D65 rule 168 closed the closed sets against misspelled literals, and D65
2994
+ * item 3 makes a `const` string a first-class design token — so the two have
2995
+ * to agree. A name or a record field that folds to a bare CSS keyword is
2996
+ * checked exactly as the literal spelling of it is; anything that folds to
2997
+ * composed CSS text (a builder result, a unit, a gradient) is not a keyword
2998
+ * and is left to the type and to the builder's own checks.
2999
+ */
3000
+ foldedLookKeyword(value) {
3001
+ if (value.kind !== "IdentifierExpression" && value.kind !== "MemberExpression")
3002
+ return null;
3003
+ const folded = evaluateLookStaticExpression(value, this.lookStaticValues);
3004
+ if (folded?.kind !== "css" || !/^[A-Za-z][A-Za-z0-9-]*$/u.test(folded.value))
3005
+ return null;
3006
+ return [folded.value];
3007
+ }
2208
3008
  /**
2209
3009
  * LOK-D3: a bare number on a length property reaches CSS as a declaration the
2210
3010
  * browser discards. The union already rejects it; this diagnostic replaces the
@@ -2322,6 +3122,8 @@ export class VelarWebAnalyzer extends Analyzer {
2322
3122
  if (expression.tag === "a" && target === "_blank" && (typeof relation !== "string" || !relation.split(/\s+/u).includes("noopener"))) {
2323
3123
  this.diagnostics.push(diagnostic("VEL5028", "An anchor with target='_blank' requires rel='noopener'", expression.span));
2324
3124
  }
3125
+ if (expression.tag === "iframe" && attributes.has("srcdoc"))
3126
+ this.reportIframeSrcdocSandbox(expression, attributes);
2325
3127
  }
2326
3128
  if (component)
2327
3129
  this.analyzeComponentElement(expression);
@@ -2349,6 +3151,52 @@ export class VelarWebAnalyzer extends Analyzer {
2349
3151
  this.jsxDepth -= 1;
2350
3152
  return webNodeType;
2351
3153
  }
3154
+ /**
3155
+ * WEB-S3: `srcdoc` builds a whole document out of a string, and that document
3156
+ * inherits this page's origin — so it is a second raw-HTML boundary next to
3157
+ * the one the charter names (`unsafe:html`), reachable with no marker at all.
3158
+ * The marker this one gets is `sandbox`, because sandbox is what actually
3159
+ * takes the origin away; requiring it makes the boundary visible where it is
3160
+ * crossed. `allow-scripts allow-same-origin` together hands the origin back,
3161
+ * so the pair is refused by name rather than accepted as a sandbox.
3162
+ */
3163
+ reportIframeSrcdocSandbox(expression, attributes) {
3164
+ const sandbox = attributes.get("sandbox");
3165
+ if (!sandbox) {
3166
+ this.diagnostics.push(diagnostic("VEL5066", "An iframe with srcdoc builds a document from a string, and that document runs script in this page's origin; add a sandbox attribute such as sandbox=\"allow-forms\" — write sandbox=\"\" when the frame needs no capability at all", expression.span));
3167
+ return;
3168
+ }
3169
+ const tokens = typeof sandbox.value === "string" ? sandbox.value
3170
+ : sandbox.value && sandbox.value.kind === "LiteralExpression" && typeof sandbox.value.value === "string" ? sandbox.value.value
3171
+ : null;
3172
+ if (tokens === null)
3173
+ return;
3174
+ const granted = new Set(tokens.split(/\s+/u).filter(Boolean));
3175
+ if (granted.has("allow-scripts") && granted.has("allow-same-origin")) {
3176
+ this.diagnostics.push(diagnostic("VEL5066", "sandbox='allow-scripts allow-same-origin' lets the framed document remove its own sandbox, so a srcdoc frame with both is not sandboxed at all; drop one of the two", sandbox.span));
3177
+ }
3178
+ }
3179
+ /**
3180
+ * WEB-S2: the analyzer already refuses an anchor that opens a window without
3181
+ * 'noopener', so a URL attribute whose value is a script scheme cannot be the
3182
+ * one URL question it declines to ask. A written-down URL is answered here; a
3183
+ * value that arrives at run time is answered by the runtime attribute check.
3184
+ */
3185
+ reportUrlAttributeScheme(attribute) {
3186
+ if (!WEB_URL_ATTRIBUTES.has(attribute.name))
3187
+ return;
3188
+ const value = attribute.value;
3189
+ const written = typeof value === "string" ? [value] : value ? literalStringValues(value) : null;
3190
+ if (written === null)
3191
+ return;
3192
+ for (const text of written) {
3193
+ const refusal = urlSchemeRefusal(text);
3194
+ if (!refusal)
3195
+ continue;
3196
+ this.diagnostics.push(diagnostic("VEL5067", `JSX '${attribute.name}' takes a URL, and ${refusal}`, attribute.span));
3197
+ return;
3198
+ }
3199
+ }
2352
3200
  // Mirrors the emitter's keyed-children recognizer (dynamicChildLeaves): a
2353
3201
  // leaf shaped `source.map(item => <… key=… />)` — either the interpolation
2354
3202
  // itself or a '?:' branch of it — compiles to the identity-preserving keyed
@@ -2360,8 +3208,14 @@ export class VelarWebAnalyzer extends Analyzer {
2360
3208
  for (const leaf of dynamicChildLeaves(expression)) {
2361
3209
  if (!leaf.list)
2362
3210
  continue;
2363
- if (leaf.list.key)
3211
+ if (leaf.list.key) {
2364
3212
  honoredKeyRoots.add(leaf.list.arrow.body);
3213
+ // D89 A4: the list this keyed position renders by identity. A rewrite
3214
+ // of that same list is what changes every row's identity at once.
3215
+ const source = leaf.list.source.kind === "IdentifierExpression" ? this.lookup(leaf.list.source.name) : null;
3216
+ if (source)
3217
+ this.keyedListSources.add(spanIdentity(source.span));
3218
+ }
2365
3219
  else
2366
3220
  this.diagnostics.push(diagnostic("VEL5017", "A JSX list rendered with .map() requires a key on its root element", leaf.list.arrow.body.span));
2367
3221
  }
@@ -2405,8 +3259,11 @@ export class VelarWebAnalyzer extends Analyzer {
2405
3259
  const hasChildren = expression.children.some((child) => child.kind !== "JSXText" || child.value.trim().length > 0);
2406
3260
  if (hasChildren && provided.has("children"))
2407
3261
  this.diagnostics.push(diagnostic("VEL5014", `Component '${expression.tag}' receives children both as a prop and as JSX content`, expression.span));
3262
+ // D31 item 26: the message used to state the deficiency and stop. `children`
3263
+ // is an ordinary named prop, so the remedy is one declaration and the
3264
+ // diagnostic is the only place the charter's reader meets its spelling.
2408
3265
  else if (hasChildren && !component.properties.has("children"))
2409
- this.diagnostics.push(diagnostic("VEL5018", `Component '${expression.tag}' does not declare JSX children`, expression.span));
3266
+ this.diagnostics.push(diagnostic("VEL5018", `Component '${expression.tag}' does not declare JSX children; declare a 'children: WebNode' prop to accept them`, expression.span));
2410
3267
  else if (hasChildren) {
2411
3268
  provided.add("children");
2412
3269
  this.requireAssignable(webNodeType, component.properties.get("children"), expression.span);
@@ -2476,6 +3333,10 @@ export class VelarWebAnalyzer extends Analyzer {
2476
3333
  if (isWebComponentConstructor(component) && webComponentIntrinsic(component) === "web.router" && attribute.name === "fallback" && actual.kind !== "null" && actual.kind !== "any") {
2477
3334
  this.checkWebRouteComponent(actual, attribute.span, "A Router fallback");
2478
3335
  }
3336
+ if (isWebComponentConstructor(component) && webComponentIntrinsic(component) === "web.router" && attribute.name === "routes"
3337
+ && attribute.value !== null && typeof attribute.value !== "string") {
3338
+ this.checkWebRouteRecords(attribute.value);
3339
+ }
2479
3340
  this.requireAssignable(actual, expected, attribute.span);
2480
3341
  }
2481
3342
  }
@@ -2506,6 +3367,46 @@ export class VelarWebAnalyzer extends Analyzer {
2506
3367
  const actual = this.inferExpression(value);
2507
3368
  if (!this.isLookInput(actual))
2508
3369
  this.diagnostics.push(diagnostic("VEL5040", `JSX look requires Look, Look?, or a list of Look values; received ${describeType(actual)}`, attribute.span));
3370
+ if (value.kind === "ListExpression")
3371
+ this.reportIndependentLookCollisions(value, attribute);
3372
+ }
3373
+ /**
3374
+ * Composition is how a Look overrides another one: `...baseLook` puts the two
3375
+ * in an order the reader can see, and everything after it wins. Two looks
3376
+ * placed side by side on one element state no order at all, so a property both
3377
+ * of them set has no answer the source gives — the winner used to fall out of
3378
+ * whichever rule the stylesheet happened to carry last. The shape is refused
3379
+ * rather than ordered by fiat, and the refusal names the spelling that states
3380
+ * the order the author meant.
3381
+ */
3382
+ reportIndependentLookCollisions(value, attribute) {
3383
+ const entries = [];
3384
+ for (const element of value.elements) {
3385
+ if (element.kind !== "IdentifierExpression")
3386
+ continue;
3387
+ const look = this.lookDeclarations.get(element.name);
3388
+ if (look)
3389
+ entries.push({ name: element.name, look });
3390
+ }
3391
+ for (const [index, first] of entries.entries()) {
3392
+ for (const second of entries.slice(index + 1)) {
3393
+ if (first.name === second.name)
3394
+ continue;
3395
+ const left = lookContributions(first.look, this.lookDeclarations);
3396
+ const right = lookContributions(second.look, this.lookDeclarations);
3397
+ // A look that composes the other is already ordered against it: the
3398
+ // spread says which one is the base, so nothing here is ambiguous.
3399
+ if (left.composed.has(second.name) || right.composed.has(first.name))
3400
+ continue;
3401
+ const collision = [...left.properties].find((property) => right.properties.has(property));
3402
+ if (collision === undefined)
3403
+ continue;
3404
+ const property = collision.slice(collision.indexOf(":") + 1);
3405
+ const target = collision.slice(0, collision.indexOf(":"));
3406
+ this.diagnostics.push(diagnostic("VEL5068", `Look '${first.name}' and Look '${second.name}' both set '${property}'${target ? ` on '@${target}'` : ""}, and placing them side by side states no order between them; write one Look that starts with '...${first.name}' and overrides '${property}' from there, then pass that one`, attribute.span));
3407
+ return;
3408
+ }
3409
+ }
2509
3410
  }
2510
3411
  analyzeComponentRef(expression, attribute, component) {
2511
3412
  const value = attribute.value;
@@ -2531,6 +3432,7 @@ export class VelarWebAnalyzer extends Analyzer {
2531
3432
  }
2532
3433
  analyzeNativeJsxAttribute(expression, attribute) {
2533
3434
  const value = attribute.value;
3435
+ this.reportUrlAttributeScheme(attribute);
2534
3436
  const eventName = attribute.name.startsWith("on:") ? attribute.name.slice(3).split(".")[0] ?? "" : "";
2535
3437
  const expectedEvent = eventName ? webEventType(eventName) : null;
2536
3438
  // GRM-A4: the declared handler type returns null. `() => {}` after a fat
@@ -2570,6 +3472,14 @@ export class VelarWebAnalyzer extends Analyzer {
2570
3472
  if (!isInvalidType(inferred) && inferred.kind !== "any" && !this.isOptionalString(inferred)) {
2571
3473
  this.diagnostics.push(diagnostic("VEL5047", `unsafe:html requires string or string?, received ${describeType(inferred)}`, attribute.span));
2572
3474
  }
3475
+ // D90 coherence-3, one step sideways: `unsafe:` is a closed prefix with
3476
+ // exactly one member, so `unsafe:script=` is wrong by construction rather
3477
+ // than merely unrecognised — and it used to be emitted verbatim as a dead
3478
+ // attribute, which is the worst possible answer for a name that reads like
3479
+ // an escape hatch the author believes they opened.
3480
+ }
3481
+ else if (attribute.name.startsWith("unsafe:")) {
3482
+ this.diagnostics.push(diagnostic("VEL5015", `Unknown escape hatch '${attribute.name}'; 'unsafe:html' is the only one an element has, and it takes the HTML text as a string`, attribute.span));
2573
3483
  }
2574
3484
  else if (attribute.name === "bind:value") {
2575
3485
  if (!this.isWritableBindTarget(value)) {
@@ -2611,20 +3521,54 @@ export class VelarWebAnalyzer extends Analyzer {
2611
3521
  else if (target.kind !== "any" && (target.kind !== "named" || !accepted.has(target.name)))
2612
3522
  this.diagnostics.push(diagnostic("VEL5024", `A <${expression.tag}> ref requires ${expected}? or a parent element type`, attribute.span));
2613
3523
  }
2614
- }
2615
- else if (attribute.name === "bind") {
2616
- this.diagnostics.push(diagnostic("VEL5019", "Use 'bind:value={name}'; the bind directive names the bound property, such as bind:value or bind:checked", attribute.span));
2617
- }
2618
- else if (/^on[A-Z]/u.test(attribute.name)) {
3524
+ // The three branches above are the whole `bind:` family, so a fourth suffix
3525
+ // names no binding — and it used to reach the attribute emitter and render
3526
+ // a dead `bind:foo` attribute, silently binding nothing. Same shape as the
3527
+ // React-spelling rule: a closed VelarScript vocabulary, so an outside name
3528
+ // is wrong rather than unknown.
3529
+ }
3530
+ else if (attribute.name === "bind" || attribute.name.startsWith("bind:")) {
3531
+ const property = attribute.name.slice("bind:".length);
3532
+ const nearest = property ? nearestLookName(property, ["value", "checked", "group"]) : null;
3533
+ this.diagnostics.push(diagnostic("VEL5019", property
3534
+ ? `Unknown binding 'bind:${property}'${nearest ? `; did you mean 'bind:${nearest}'?` : "; an element binds bind:value for a field, bind:checked for a flag, and bind:group for a set of choices"}`
3535
+ : "Use 'bind:value={name}'; the bind directive names the bound property, such as bind:value or bind:checked", attribute.span));
3536
+ // WEB-S1: the guard used to be anchored on an uppercase letter, which
3537
+ // closed the React reflex `onClick=` and left the lowercase HTML spelling
3538
+ // open. Both are the same attribute — an HTML attribute name is matched
3539
+ // ASCII-case-insensitively, and `setAttribute` lowercases on an HTML
3540
+ // element, so `onClick` and `ONCLICK` reach `onclick` too. That attribute
3541
+ // is the one that matters: the browser compiles its value as script, so any
3542
+ // string routed there is executable code in the application's origin, which
3543
+ // the charter reserves for `unsafe:html`. So a native element reserves the
3544
+ // whole `on` prefix by name rather than by a roster of handler names: a
3545
+ // roster leaves the next handler spelling open, and every event is written
3546
+ // with the `on:` directive anyway. The lookahead keeps that directive out.
3547
+ }
3548
+ else if (/^on(?!:)/iu.test(attribute.name)) {
3549
+ const camel = attribute.name.slice(2);
3550
+ const event = camel === "DoubleClick" || camel === "DblClick" ? "dblclick" : camel.toLowerCase();
3551
+ // Only a name the browser really compiles as script earns the executable
3552
+ // clause; a name that is no event at all is refused by the prefix rule,
3553
+ // not by the browser, and a message that claimed otherwise would state a
3554
+ // rule the author did not hit. The roster answering that question is
3555
+ // HTML's handler attributes, read case-insensitively as the browser reads
3556
+ // them — the `on:` directive vocabulary is a different question and
3557
+ // answered it wrongly in both directions.
3558
+ const executable = htmlEventHandlerAttributes.has(attribute.name.toLowerCase());
3559
+ // A concrete `on:` replacement is worth naming only when the remainder is
3560
+ // a real event; `onward` would otherwise be answered with `on:ward`.
3561
+ const named = executable || nativeDomEventNames.has(event);
3562
+ const script = executable
3563
+ ? ` — an '${attribute.name}' attribute is compiled as script by the browser, so any value written there runs`
3564
+ : "";
2619
3565
  if (attribute.name === "onEnter") {
2620
3566
  this.diagnostics.push(diagnostic("VEL5025", "Use 'on:keydown' with a handler that checks 'event.key == \"Enter\"'; VelarScript has no dedicated enter-key event", attribute.span));
2621
3567
  }
2622
3568
  else {
2623
- const camel = attribute.name.slice(2);
2624
- const event = camel === "DoubleClick" || camel === "DblClick" ? "dblclick" : camel.toLowerCase();
2625
- this.diagnostics.push(diagnostic("VEL5025", nativeDomEventNames.has(event)
2626
- ? `Use 'on:${event}'; VelarScript event attributes use the on: directive`
2627
- : "Use an 'on:event' directive with a native DOM event name, such as 'on:click' or 'on:keydown'", attribute.span));
3569
+ this.diagnostics.push(diagnostic("VEL5025", named
3570
+ ? `Use 'on:${event}'; VelarScript event attributes use the on: directive, written 'on:${event}={handler}'${script}`
3571
+ : "Use an 'on:event' directive with a native DOM event name, such as 'on:click={handler}' or 'on:keydown={handler}'; an element reserves every attribute name beginning with 'on' other than the 'on:' directive itself, because the handler spellings among them — 'onclick', 'onerror', 'onload' — are executable script in the browser", attribute.span));
2628
3572
  }
2629
3573
  }
2630
3574
  else if (attribute.name.startsWith("on:")) {
@@ -2653,14 +3597,132 @@ export class VelarWebAnalyzer extends Analyzer {
2653
3597
  }
2654
3598
  else if (attribute.name === "key" && !isInvalidType(inferred) && inferred.kind !== "string" && inferred.kind !== "number" && inferred.kind !== "enum" && inferred.kind !== "enumMember" && inferred.kind !== "any") {
2655
3599
  this.diagnostics.push(diagnostic("VEL5022", "A JSX key must be a string, string-backed enum, or number", attribute.span));
2656
- }
2657
- else if (!isInvalidType(inferred) && !this.isJsxAttributeValue(inferred)) {
3600
+ // The name check sits at the tail of the chain so every directive above it
3601
+ // look:, class:, on:, bind:, ref, key — keeps owning its own spelling and
3602
+ // is never read as an HTML attribute name. It reports at most once per
3603
+ // attribute and short-circuits the value-shape message, so a React spelling
3604
+ // is answered with its successor rather than with two half-answers.
3605
+ }
3606
+ else if (!this.reportNativeAttributeSpelling(expression, attribute)
3607
+ && !isInvalidType(inferred) && !this.isJsxAttributeValue(inferred)) {
2658
3608
  this.diagnostics.push(diagnostic("VEL5047", `Native JSX attributes require text, finite numbers, bool, enums, or null; received ${describeType(inferred)}`, attribute.span));
2659
3609
  }
2660
3610
  if (attribute.name.startsWith("on:click") && !["button", "a", "input", "select", "textarea", "summary"].includes(expression.tag)
2661
3611
  && !expression.attributes.some((item) => item.name === "role"))
2662
3612
  this.diagnostics.push(diagnostic("VEL5023", `Clickable <${expression.tag}> requires an explicit role`, expression.span));
2663
3613
  }
3614
+ /**
3615
+ * D90 coherence-3: DOM attribute names and ARIA are documented as checked
3616
+ * surfaces, and nothing checked them — `className="panel"` compiled clean and
3617
+ * emitted a dead attribute, while the sibling React reflex `onClick=` on the
3618
+ * same element was already refused. So a model got a clean bill of health on
3619
+ * exactly the half of its React habit that silently breaks the page.
3620
+ *
3621
+ * The rule diagnoses names that are KNOWN wrong, never names that are merely
3622
+ * unknown. HTML lets a document carry attributes no roster can enumerate, so
3623
+ * `foo="bar"`, `data-*`, and a framework's own prefixes stay legal; a false
3624
+ * positive there would block a correct program, which is worse than the
3625
+ * silence it replaces. Three closed rosters answer three closed questions:
3626
+ * the React / JavaScript-property spellings, the ARIA attribute names (ARIA,
3627
+ * unlike HTML, admits no custom names), and the ARIA and role vocabularies.
3628
+ *
3629
+ * D61 bounds the value half. It ruled that an `aria-*` attribute given a
3630
+ * `bool` renders the literal text "true"/"false", and explicitly rejected
3631
+ * making the author write `aria-pressed={x ? "true" : "false"}` as ceremony.
3632
+ * So a token vocabulary is read only against a string literal; an expression
3633
+ * value is a runtime question and is never touched here.
3634
+ *
3635
+ * Returns true when it reported, so the caller does not stack a value-shape
3636
+ * message on an attribute whose name is already answered.
3637
+ */
3638
+ reportNativeAttributeSpelling(expression, attribute) {
3639
+ // A custom element owns its own attribute vocabulary, and an unknown tag was
3640
+ // already reported as a tag; neither is a surface these rosters describe.
3641
+ if (!WEB_NATIVE_ELEMENTS.has(expression.tag))
3642
+ return false;
3643
+ const name = attribute.name;
3644
+ // The attribute span opens on the name, so the name occupies its first
3645
+ // `name.length` characters — the range a rename rewrites, leaving the value
3646
+ // exactly as written.
3647
+ const nameSpan = { start: attribute.span.start, end: attribute.span.start + name.length };
3648
+ const rename = (write) => ({ fix: { title: `Use '${write}'`, edits: [{ span: nameSpan, text: write }] } });
3649
+ const spelling = WEB_MISSPELLED_ATTRIBUTES.get(name);
3650
+ if (spelling) {
3651
+ this.diagnostics.push({
3652
+ code: "VEL5070",
3653
+ message: `'${name}' is not a native attribute name; write '${spelling.write}'${spelling.note ? ` ${spelling.note}` : ""}`,
3654
+ span: attribute.span,
3655
+ ...(spelling.note === undefined ? rename(spelling.write) : {}),
3656
+ });
3657
+ return true;
3658
+ }
3659
+ if (/^aria[A-Z]/u.test(name)) {
3660
+ // Every ARIA attribute is `aria-` followed by one lowercase word, so
3661
+ // lowercasing the remainder recovers the spelling whenever one exists.
3662
+ const hyphenated = `aria-${name.slice(4).toLowerCase()}`;
3663
+ const known = WEB_ARIA_ATTRIBUTES.has(hyphenated);
3664
+ this.diagnostics.push({
3665
+ code: "VEL5070",
3666
+ message: known
3667
+ ? `'${name}' is not a native attribute name; ARIA attribute names are hyphenated — write '${hyphenated}'`
3668
+ : `'${name}' is not a native attribute name; ARIA attribute names are hyphenated and lowercase, such as 'aria-label'`,
3669
+ span: attribute.span,
3670
+ ...(known ? rename(hyphenated) : {}),
3671
+ });
3672
+ return true;
3673
+ }
3674
+ if (name.startsWith("aria-") && !WEB_ARIA_ATTRIBUTES.has(name)) {
3675
+ const nearest = nearestLookName(name, WEB_ARIA_ATTRIBUTES);
3676
+ this.diagnostics.push({
3677
+ code: "VEL5070",
3678
+ message: nearest
3679
+ ? `Unknown ARIA attribute '${name}'; did you mean '${nearest}'?`
3680
+ : `Unknown ARIA attribute '${name}'; ARIA defines a closed set of aria-* names, so a state or property it does not define belongs on a data-* attribute`,
3681
+ span: attribute.span,
3682
+ ...(nearest ? rename(nearest) : {}),
3683
+ });
3684
+ return true;
3685
+ }
3686
+ // Only a literal value is read. A fix is not registered for a value: the
3687
+ // attribute span opens on the name, so the value's own range is not known
3688
+ // here, and the message names the token instead.
3689
+ const literal = attribute.value;
3690
+ // The empty string is not an out-of-vocabulary token: ARIA reads it as the
3691
+ // attribute's own default, so `aria-hidden=""` is an uncommon but correct
3692
+ // spelling of "not hidden" and refusing it would block a working document.
3693
+ // `role=""` is already silent for the same reason — its loop skips empty
3694
+ // tokens — so this keeps the two halves of the value check agreeing.
3695
+ if (typeof literal !== "string" || literal === "")
3696
+ return false;
3697
+ const vocabulary = WEB_ARIA_ENUMERATED_VALUES.get(name);
3698
+ if (vocabulary && !vocabulary.has(literal)) {
3699
+ const nearest = nearestLookName(literal, vocabulary);
3700
+ this.diagnostics.push(diagnostic("VEL5070", `'${name}' takes one of ${[...vocabulary].join(", ")}; '${literal}' is not one of them${nearest ? ` — did you mean '${nearest}'?` : ""}`, attribute.span));
3701
+ return true;
3702
+ }
3703
+ if (name === "role") {
3704
+ // `role` takes a space-separated fallback list, so each token is its own
3705
+ // question and only the unknown ones are named.
3706
+ let reported = false;
3707
+ for (const token of literal.split(/\s+/u)) {
3708
+ if (!token || WEB_ARIA_ROLES.has(token))
3709
+ continue;
3710
+ // A published second spelling is answered as a synonym rather than as
3711
+ // an unknown name. `image` is ARIA's own later spelling of `img`, and
3712
+ // "Unknown ARIA role 'image'" would assert something false about it.
3713
+ const synonym = WEB_ARIA_ROLE_SYNONYMS.get(token);
3714
+ const nearest = synonym ? null : nearestLookName(token, WEB_ARIA_ROLES);
3715
+ this.diagnostics.push(diagnostic("VEL5070", synonym
3716
+ ? `ARIA publishes '${token}' and '${synonym}' as one role; VelarScript writes '${synonym}'`
3717
+ : nearest
3718
+ ? `Unknown ARIA role '${token}'; did you mean '${nearest}'?`
3719
+ : `Unknown ARIA role '${token}'; role takes an ARIA role name such as 'button', 'dialog', or 'status'`, attribute.span));
3720
+ reported = true;
3721
+ }
3722
+ return reported;
3723
+ }
3724
+ return false;
3725
+ }
2664
3726
  /**
2665
3727
  * GRM-A4: an event handler runs for effect and returns null. The hole this
2666
3728
  * closes is `on:click={() => {}}`: after a fat arrow, braces build a record,
@@ -2842,12 +3904,71 @@ export class VelarWebAnalyzer extends Analyzer {
2842
3904
  return expanded.members.every((member) => this.isOptionalString(member));
2843
3905
  return false;
2844
3906
  }
3907
+ /**
3908
+ * The elements of a `<Router routes={...}>` list literal, one at a time.
3909
+ *
3910
+ * A route written as `route("/a", Panel)` is checked at that call; the record
3911
+ * that call returns — `{path: "/a", component: Panel}` — is a legal spelling
3912
+ * of the same value, reaches the same runtime position, and until now was
3913
+ * checked by nothing: `{path: "no-leading-slash", component: 5}` compiled
3914
+ * clean and handed `5` to the Router as a component. Closing the sink rather
3915
+ * than the spelling means asking the same questions wherever a route arrives,
3916
+ * so this reports exactly what `route(...)` reports, word for word.
3917
+ *
3918
+ * The runtime is already the second referee (D90 R19): `routerTable`
3919
+ * validates every path and refuses a component that is not callable. Nothing
3920
+ * here changes which programs run — it moves a refusal the author would have
3921
+ * met at mount to the place the source shows the mistake.
3922
+ *
3923
+ * The `any` component slot is skipped here rather than inside
3924
+ * `checkWebRouteComponent`, which is why that method carries no `any` arm:
3925
+ * this loop and the Router `fallback` attribute are its only two callers, and
3926
+ * both filter first. An `any` reaches this slot for real — `web.lazy` answers
3927
+ * `anyType` from each of its error paths — and it arrives with the author's
3928
+ * real message already reported, so a second "received any" would be a
3929
+ * cascade. The `route(...)` twin skips it for the same reason.
3930
+ */
3931
+ checkWebRouteRecords(expression) {
3932
+ if (expression.kind !== "ListExpression")
3933
+ return;
3934
+ for (const element of expression.elements) {
3935
+ if (element.kind !== "ObjectExpression")
3936
+ continue;
3937
+ for (const entry of element.properties) {
3938
+ if (entry.kind !== "ObjectProperty")
3939
+ continue;
3940
+ if (entry.name === "path") {
3941
+ const path = entry.value;
3942
+ if (path.kind === "LiteralExpression" && typeof path.value === "string") {
3943
+ checkRoutePath(path.value, path.span, (message, span) => this.typeError(message, span));
3944
+ }
3945
+ continue;
3946
+ }
3947
+ if (entry.name !== "component")
3948
+ continue;
3949
+ // The attribute has already been inferred as a whole, so every
3950
+ // sub-expression here has been reported once. This second pass exists
3951
+ // only to read the component slot's type back; anything it says is a
3952
+ // repeat of what the author already has, and is dropped. Advisories
3953
+ // need no such cursor — `advise` is deduplicated by code and span
3954
+ // precisely so a re-analysis cannot raise one twice.
3955
+ const reported = this.diagnostics.length;
3956
+ const type = this.inferExpression(entry.value);
3957
+ this.diagnostics.splice(reported);
3958
+ if (type.kind === "any")
3959
+ continue;
3960
+ this.checkWebRouteComponent(type, entry.value.span, "A route");
3961
+ }
3962
+ }
3963
+ }
2845
3964
  checkWebRouteComponent(type, sourceSpan, subject) {
2846
3965
  if (isInvalidType(type))
2847
3966
  return;
2848
3967
  if (!isWebComponentType(type)) {
2849
- if (type.kind !== "any")
2850
- this.typeError(`${subject} requires a component, received ${describeType(type)}`, sourceSpan);
3968
+ // No `any` arm, unlike the `route(...)` twin above: both callers — the
3969
+ // Router `fallback` attribute and `checkWebRouteRecords` — already filter
3970
+ // `any` out before they call here, so a branch for it could never run.
3971
+ this.typeError(`${subject} requires a component, received ${describeType(type)}`, sourceSpan);
2851
3972
  return;
2852
3973
  }
2853
3974
  const unsupported = [...type.requiredProperties].filter((name) => name !== "route");