@velarscript/web 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/analyzer.d.ts +164 -23
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +1254 -133
- package/dist/analyzer.js.map +1 -1
- package/dist/ast.d.ts +11 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +22 -0
- package/dist/ast.js.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +69 -44
- package/dist/compiler.js.map +1 -1
- package/dist/css-string.d.ts +32 -0
- package/dist/css-string.d.ts.map +1 -0
- package/dist/css-string.js +66 -0
- package/dist/css-string.js.map +1 -0
- package/dist/css-tokens.d.ts +16 -0
- package/dist/css-tokens.d.ts.map +1 -1
- package/dist/css-tokens.js +84 -0
- package/dist/css-tokens.js.map +1 -1
- package/dist/elements.d.ts +55 -0
- package/dist/elements.d.ts.map +1 -1
- package/dist/elements.js +152 -0
- package/dist/elements.js.map +1 -1
- package/dist/emitter.d.ts +10 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +588 -159
- package/dist/emitter.js.map +1 -1
- package/dist/keyframes.d.ts +17 -0
- package/dist/keyframes.d.ts.map +1 -1
- package/dist/keyframes.js +28 -9
- package/dist/keyframes.js.map +1 -1
- package/dist/lexer.d.ts +21 -7
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +32 -12
- package/dist/lexer.js.map +1 -1
- package/dist/look-static.d.ts.map +1 -1
- package/dist/look-static.js +120 -20
- package/dist/look-static.js.map +1 -1
- package/dist/look.d.ts +15 -0
- package/dist/look.d.ts.map +1 -1
- package/dist/look.js +7 -2
- package/dist/look.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +15 -20
- package/dist/parser.js.map +1 -1
- package/dist/runtime-foundation.d.ts.map +1 -1
- package/dist/runtime-foundation.js +321 -70
- package/dist/runtime-foundation.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +320 -179
- package/dist/runtime.js.map +1 -1
- package/dist/stable-order.d.ts +16 -0
- package/dist/stable-order.d.ts.map +1 -0
- package/dist/stable-order.js +18 -0
- package/dist/stable-order.js.map +1 -0
- package/dist/types.d.ts +0 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -16
- package/dist/types.js.map +1 -1
- package/dist/websocket-runtime.d.ts.map +1 -1
- package/dist/websocket-runtime.js +9 -5
- package/dist/websocket-runtime.js.map +1 -1
- package/dist/worker-runtime.d.ts.map +1 -1
- package/dist/worker-runtime.js +9 -7
- package/dist/worker-runtime.js.map +1 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
468
|
-
const elseValues =
|
|
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
|
|
529
|
-
* to
|
|
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
|
|
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 `!(${
|
|
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 `(${
|
|
605
|
+
return `(${lookRuntimeSignature(expression.left)}${expression.operator}${lookRuntimeSignature(expression.right)})`;
|
|
541
606
|
if (expression.kind === "MemberExpression")
|
|
542
|
-
return `${
|
|
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
|
|
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,
|
|
881
|
+
function checkRoutePath(path, sourceSpan, report) {
|
|
673
882
|
if (!path.startsWith("/"))
|
|
674
|
-
|
|
883
|
+
report("A route path must start with '/'", sourceSpan);
|
|
675
884
|
if (path.includes("?") || path.includes("#"))
|
|
676
|
-
|
|
885
|
+
report("A route path describes only a pathname; read query and hash from RouteContext", sourceSpan);
|
|
677
886
|
if (path.includes("\\"))
|
|
678
|
-
|
|
887
|
+
report("A route path cannot contain a backslash", sourceSpan);
|
|
679
888
|
if (path.length > 1 && path.endsWith("/"))
|
|
680
|
-
|
|
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
|
-
|
|
894
|
+
report("A route path cannot contain an empty segment", sourceSpan);
|
|
686
895
|
if (segment === "*") {
|
|
687
896
|
if (index !== segments.length - 1)
|
|
688
|
-
|
|
897
|
+
report("A route wildcard must be the final segment", sourceSpan);
|
|
689
898
|
if (parameters.has("wildcard"))
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
909
|
+
report("A route parameter requires a valid name", sourceSpan);
|
|
701
910
|
else if (parameters.has(name))
|
|
702
|
-
|
|
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:
|
|
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
|
|
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.
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
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
|
|
1305
|
-
* not called
|
|
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;
|
|
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 (
|
|
2006
|
+
if (watchSubjectPath(expression))
|
|
1314
2007
|
return;
|
|
1315
|
-
|
|
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
|
|
1384
|
-
*
|
|
1385
|
-
* declaration. The declaration is recorded before the core walks the module
|
|
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
|
|
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
|
|
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
|
|
2184
|
+
/** Returns true when the name is one of the retired accessor globals this pass owns. */
|
|
1414
2185
|
recordRetiredAccessorRead(expression) {
|
|
1415
|
-
if (expression.name
|
|
1416
|
-
if (this.lookup(
|
|
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
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
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} =
|
|
1457
|
-
: ` Where the reader itself is passed on rather than read here,
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
1866
|
-
|
|
1867
|
-
this.analyzeLookEntries(entry.
|
|
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 ?
|
|
2745
|
+
const position = named === null ? index : parameters?.indexOf(named) ?? -1;
|
|
1981
2746
|
const range = position >= 0 ? ranges?.[position] : undefined;
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2850
|
-
|
|
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");
|