@velarscript/web 0.10.1
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/LICENSE +201 -0
- package/README.md +89 -0
- package/dist/analyzer.d.ts +302 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +2931 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/ast.d.ts +220 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +102 -0
- package/dist/ast.js.map +1 -0
- package/dist/browser-test.d.ts +29 -0
- package/dist/browser-test.d.ts.map +1 -0
- package/dist/browser-test.js +33 -0
- package/dist/browser-test.js.map +1 -0
- package/dist/compiler.d.ts +9 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +715 -0
- package/dist/compiler.js.map +1 -0
- package/dist/css-tokens.d.ts +46 -0
- package/dist/css-tokens.d.ts.map +1 -0
- package/dist/css-tokens.js +363 -0
- package/dist/css-tokens.js.map +1 -0
- package/dist/editor.d.ts +3 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +147 -0
- package/dist/editor.js.map +1 -0
- package/dist/elements.d.ts +11 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +24 -0
- package/dist/elements.js.map +1 -0
- package/dist/emitter.d.ts +79 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +2699 -0
- package/dist/emitter.js.map +1 -0
- package/dist/host.d.ts +7 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +224 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/inspection.d.ts +3 -0
- package/dist/inspection.d.ts.map +1 -0
- package/dist/inspection.js +82 -0
- package/dist/inspection.js.map +1 -0
- package/dist/keyframes.d.ts +16 -0
- package/dist/keyframes.d.ts.map +1 -0
- package/dist/keyframes.js +29 -0
- package/dist/keyframes.js.map +1 -0
- package/dist/lexer.d.ts +85 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +422 -0
- package/dist/lexer.js.map +1 -0
- package/dist/look-static.d.ts +31 -0
- package/dist/look-static.d.ts.map +1 -0
- package/dist/look-static.js +266 -0
- package/dist/look-static.js.map +1 -0
- package/dist/look.d.ts +134 -0
- package/dist/look.d.ts.map +1 -0
- package/dist/look.js +627 -0
- package/dist/look.js.map +1 -0
- package/dist/parser.d.ts +71 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +976 -0
- package/dist/parser.js.map +1 -0
- package/dist/project-config.d.ts +33 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +199 -0
- package/dist/project-config.js.map +1 -0
- package/dist/runtime-foundation.d.ts +7 -0
- package/dist/runtime-foundation.d.ts.map +1 -0
- package/dist/runtime-foundation.js +1245 -0
- package/dist/runtime-foundation.js.map +1 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +3444 -0
- package/dist/runtime.js.map +1 -0
- package/dist/semantic.d.ts +3 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +150 -0
- package/dist/semantic.js.map +1 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +178 -0
- package/dist/types.js.map +1 -0
- package/dist/websocket-runtime.d.ts +2 -0
- package/dist/websocket-runtime.d.ts.map +1 -0
- package/dist/websocket-runtime.js +30 -0
- package/dist/websocket-runtime.js.map +1 -0
- package/dist/worker-runtime.d.ts +2 -0
- package/dist/worker-runtime.d.ts.map +1 -0
- package/dist/worker-runtime.js +72 -0
- package/dist/worker-runtime.js.map +1 -0
- package/package.json +52 -0
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,2931 @@
|
|
|
1
|
+
import { semanticTypeIdentity } from "@velarscript/compiler";
|
|
2
|
+
import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
|
|
3
|
+
import { BROWSER_TEST_MODULE, BROWSER_TEST_SOURCE_SUFFIX, browserTestImportGuidance } from "./browser-test.js";
|
|
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";
|
|
7
|
+
import { keyframeCssValue } from "./keyframes.js";
|
|
8
|
+
import { dynamicChildLeaves } from "./emitter.js";
|
|
9
|
+
import { isWebCustomElementName, WEB_NATIVE_ELEMENTS } from "./elements.js";
|
|
10
|
+
import { isWebExpression, isWebJsx, isWebKeyframes, isWebLook, isWebStatement, isWebUnit, } from "./ast.js";
|
|
11
|
+
import { CACHED_INTRINSIC_TYPE, isWebComponentConstructor, isWebComponentType, isWebComputedExport, isWebNodeType, normalizeWebComponentType, webComponentConstructor, webComponentHandle, webComponentIntrinsic, webComponentName, webNodeType, WEB_EVENT_TYPE_NAMES, WEB_OWNED_TYPE_NAMES, } from "./types.js";
|
|
12
|
+
// The canonical nominal identity of the Web RouteContext record. Route checks
|
|
13
|
+
// probe with this identity so they succeed in modules that use route() without
|
|
14
|
+
// importing RouteContext by name.
|
|
15
|
+
export const routeContextIdentity = "@velarscript/web:velar/web#type:RouteContext";
|
|
16
|
+
const removedJsxControlAttributes = new Set(["if", "else-if", "else"]);
|
|
17
|
+
const nativeDomEventNames = new Set([
|
|
18
|
+
"click", "dblclick", "input", "beforeinput", "change", "submit", "reset", "invalid", "select", "toggle", "close",
|
|
19
|
+
"keydown", "keyup", "keypress", "focus", "blur", "focusin", "focusout", "scroll", "wheel",
|
|
20
|
+
"mousedown", "mouseup", "mousemove", "mouseenter", "mouseleave", "mouseover", "mouseout", "contextmenu",
|
|
21
|
+
"pointerdown", "pointerup", "pointermove", "pointerenter", "pointerleave", "pointerover", "pointerout", "pointercancel",
|
|
22
|
+
"touchstart", "touchend", "touchmove", "touchcancel",
|
|
23
|
+
"dragstart", "dragend", "dragover", "dragenter", "dragleave", "drop", "drag",
|
|
24
|
+
"compositionstart", "compositionupdate", "compositionend",
|
|
25
|
+
"copy", "cut", "paste", "load", "error", "transitionend", "animationend", "play", "pause", "ended",
|
|
26
|
+
]);
|
|
27
|
+
const textualWebPrimitiveNames = new Set(["Length", "Percentage", "LengthPercentage", "TrackFraction", "Color", "Duration", "Angle"]);
|
|
28
|
+
// D72 rule 186: derived from the published table, not restated beside it.
|
|
29
|
+
const webEventTypeNames = WEB_EVENT_TYPE_NAMES;
|
|
30
|
+
const webEventDeadFields = new Set(["target", "currentTarget", "value", "checked"]);
|
|
31
|
+
const diagnostic = (code, message, sourceSpan) => ({ code, message, span: sourceSpan });
|
|
32
|
+
const bindTargetGuidance = (directive) => `${directive} requires a writable reactive location: a state name, or a field or index path on one such as ${directive}={form.name} or ${directive}={items[0]}`;
|
|
33
|
+
const LOOK_CONDITION_TERM_LIMIT = 32;
|
|
34
|
+
const lookLength = { kind: "named", name: "Length" };
|
|
35
|
+
const lookPercentage = { kind: "named", name: "Percentage" };
|
|
36
|
+
const lookLengthPercentage = { kind: "named", name: "LengthPercentage" };
|
|
37
|
+
// LOK-D3: a length property never accepts a bare number. `width = 100` used to
|
|
38
|
+
// compile and reach CSS as the invalid declaration `width: 100`, which computes
|
|
39
|
+
// to `auto`; the unions below carry only spelled units, and the unitless-legal
|
|
40
|
+
// properties (opacity, zIndex, lineHeight, flex*, order, scale, aspectRatio,
|
|
41
|
+
// fontWeight) keep `number` individually.
|
|
42
|
+
const lookMetric = { kind: "union", members: [lookLength, lookPercentage, lookLengthPercentage] };
|
|
43
|
+
const lookColor = { kind: "named", name: "Color" };
|
|
44
|
+
const lookImage = { kind: "named", name: "Image" };
|
|
45
|
+
const lookBorder = { kind: "named", name: "Border" };
|
|
46
|
+
const lookShadow = { kind: "named", name: "Shadow" };
|
|
47
|
+
const lookDuration = { kind: "named", name: "Duration" };
|
|
48
|
+
const lookAngle = { kind: "named", name: "Angle" };
|
|
49
|
+
const lookTrackList = { kind: "named", name: "TrackList" };
|
|
50
|
+
const lookTransition = { kind: "named", name: "Transition" };
|
|
51
|
+
const lookAnimation = { kind: "named", name: "Animation" };
|
|
52
|
+
const lookSpacing = { kind: "named", name: "Spacing" };
|
|
53
|
+
const lookMetricOrSpacing = { kind: "union", members: [lookMetric, lookSpacing] };
|
|
54
|
+
const lookPropertyType = (kind) => {
|
|
55
|
+
switch (kind) {
|
|
56
|
+
case "animation": return { kind: "union", members: [lookAnimation, { kind: "list", element: lookAnimation }] };
|
|
57
|
+
// D73 rule 187: a published keyword must be reachable. These three kinds
|
|
58
|
+
// used to type-refuse every string, so `zIndex = "auto"` -- the CSS initial
|
|
59
|
+
// value -- and the five CSS-wide keywords every other property takes were
|
|
60
|
+
// refused by the type before the closed set could answer. Accepting the
|
|
61
|
+
// string is what makes the set they now publish a surface rather than a
|
|
62
|
+
// promise (D50 rule 92); a value outside the set is still refused, by name.
|
|
63
|
+
case "angle": return { kind: "union", members: [lookAngle, stringType] };
|
|
64
|
+
case "background": return { kind: "union", members: [lookColor, lookImage, stringType] };
|
|
65
|
+
case "border": return { kind: "union", members: [lookBorder, stringType] };
|
|
66
|
+
case "color": return { kind: "union", members: [lookColor, stringType] };
|
|
67
|
+
case "duration": return { kind: "union", members: [lookDuration, stringType] };
|
|
68
|
+
case "image": return { kind: "union", members: [lookImage, stringType] };
|
|
69
|
+
case "line-height": return { kind: "union", members: [numberType, lookLength, stringType] };
|
|
70
|
+
case "metric": return { kind: "union", members: [lookMetricOrSpacing, stringType] };
|
|
71
|
+
case "number": return { kind: "union", members: [numberType, stringType] };
|
|
72
|
+
case "number-keyword": return { kind: "union", members: [numberType, lookSpacing, stringType] };
|
|
73
|
+
case "shadow": return { kind: "union", members: [lookShadow, stringType] };
|
|
74
|
+
case "track": return { kind: "union", members: [lookTrackList, stringType] };
|
|
75
|
+
case "transition": return { kind: "union", members: [lookTransition, stringType] };
|
|
76
|
+
case "filter":
|
|
77
|
+
case "keyword":
|
|
78
|
+
case "text":
|
|
79
|
+
case "transform":
|
|
80
|
+
return stringType;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const LOOK_PROPERTY_TYPES = new Map([...LOOK_PROPERTY_VALUE_KINDS]
|
|
84
|
+
.map(([name, kind]) => [name, lookPropertyType(kind)]));
|
|
85
|
+
/**
|
|
86
|
+
* The whole read, from the module name to a working line. A blind model needed
|
|
87
|
+
* five guesses to get here because each diagnostic answered only the step it
|
|
88
|
+
* was standing on: the export name, then the argument count, then that the
|
|
89
|
+
* second argument is a runtime type, then that a primitive spelling is not a
|
|
90
|
+
* value. Storage already parses and validates the stored JSON, so the one thing
|
|
91
|
+
* still missing is the named type to validate against.
|
|
92
|
+
*/
|
|
93
|
+
function storageReadGuidance(call) {
|
|
94
|
+
return `${call} validates what it reads and parses the stored JSON itself, so its second argument is a named runtime type: declare one — 'type SavedItems = List<Item>' — then read with 'storage.get("items", SavedItems, [])', whose third argument is the fallback for missing or invalid data, and write it back with 'storage.set("items", items)'. A primitive spelling ('string') and a generic spelling ('List<Item>') are types, not values; only a declared type, enum, or alias name is one.`;
|
|
95
|
+
}
|
|
96
|
+
export function inferWebIntrinsic(context) {
|
|
97
|
+
const { intrinsic, argumentAt, callSpan, arity, inferAt, callbackAt, runtimeTypeAt } = context;
|
|
98
|
+
switch (intrinsic.name) {
|
|
99
|
+
case "reactive.computed": {
|
|
100
|
+
arity(1, 1);
|
|
101
|
+
const callback = callbackAt(0, [], unknownType);
|
|
102
|
+
if (callback.kind === "any")
|
|
103
|
+
return { kind: "function", parameters: [], requiredParameters: 0, result: anyType };
|
|
104
|
+
if (callback.kind !== "function" && callback.kind !== "intrinsic") {
|
|
105
|
+
context.typeError("cached requires a synchronous zero-argument function", argumentAt(0)?.span ?? callSpan);
|
|
106
|
+
return { kind: "function", parameters: [], requiredParameters: 0, result: unknownType };
|
|
107
|
+
}
|
|
108
|
+
const result = callback.result;
|
|
109
|
+
if (context.expandAliases(result).kind === "promise") {
|
|
110
|
+
context.typeError("cached cannot hold a Promise; load asynchronous data with a resource", argumentAt(0)?.span ?? callSpan);
|
|
111
|
+
}
|
|
112
|
+
return { kind: "function", parameters: [], requiredParameters: 0, result };
|
|
113
|
+
}
|
|
114
|
+
case "web.route": {
|
|
115
|
+
arity(2, 2);
|
|
116
|
+
inferAt(0, stringType);
|
|
117
|
+
const component = inferAt(1);
|
|
118
|
+
const path = argumentAt(0);
|
|
119
|
+
if (path?.kind === "LiteralExpression" && typeof path.value === "string")
|
|
120
|
+
checkRoutePath(path.value, path.span, context);
|
|
121
|
+
checkRouteComponent(component, argumentAt(1)?.span ?? callSpan, "A route", context);
|
|
122
|
+
return { kind: "object", fields: new Map([["path", stringType], ["component", component]]) };
|
|
123
|
+
}
|
|
124
|
+
case "web.lazy": {
|
|
125
|
+
arity(2, 4);
|
|
126
|
+
const loader = inferAt(0);
|
|
127
|
+
inferAt(1, stringType);
|
|
128
|
+
const loadingFallback = inferAt(2);
|
|
129
|
+
const failedFallback = inferAt(3);
|
|
130
|
+
const loaderExpression = argumentAt(0);
|
|
131
|
+
if (loaderExpression?.kind !== "ArrowFunctionExpression" || loaderExpression.parameters.length !== 0 || loaderExpression.body.kind !== "DynamicImportExpression") {
|
|
132
|
+
context.typeError("A lazy loader must be written as () => import(\"./module.vel\")", loaderExpression?.span ?? callSpan);
|
|
133
|
+
return anyType;
|
|
134
|
+
}
|
|
135
|
+
if (isInvalidType(loader))
|
|
136
|
+
return loader;
|
|
137
|
+
if (loader.kind !== "function" && loader.kind !== "any") {
|
|
138
|
+
if (loaderExpression)
|
|
139
|
+
context.typeError(`Expected a module loader, received ${describeType(loader)}`, loaderExpression.span);
|
|
140
|
+
return anyType;
|
|
141
|
+
}
|
|
142
|
+
if (loader.kind === "any")
|
|
143
|
+
return anyType;
|
|
144
|
+
if (isInvalidType(loader.result))
|
|
145
|
+
return loader.result;
|
|
146
|
+
if (loader.parameters.length !== 0)
|
|
147
|
+
context.typeError("A lazy module loader cannot receive parameters", loaderExpression?.span ?? callSpan);
|
|
148
|
+
const moduleType = loader.result.kind === "promise" ? loader.result.value : null;
|
|
149
|
+
if (!moduleType) {
|
|
150
|
+
context.typeError("A lazy module loader must return import(\"./module.vel\")", loaderExpression?.span ?? callSpan);
|
|
151
|
+
return anyType;
|
|
152
|
+
}
|
|
153
|
+
const nameExpression = argumentAt(1);
|
|
154
|
+
const name = nameExpression?.kind === "LiteralExpression" && typeof nameExpression.value === "string" ? nameExpression.value : null;
|
|
155
|
+
if (!name) {
|
|
156
|
+
context.typeError("A lazy component export name must be a string literal", nameExpression?.span ?? callSpan);
|
|
157
|
+
return anyType;
|
|
158
|
+
}
|
|
159
|
+
if (moduleType.kind !== "object") {
|
|
160
|
+
context.typeError("A lazy loader must load a checked VelarScript module", loaderExpression?.span ?? callSpan);
|
|
161
|
+
return anyType;
|
|
162
|
+
}
|
|
163
|
+
const exported = moduleType.fields.get(name);
|
|
164
|
+
if (!exported) {
|
|
165
|
+
context.typeError(`Dynamically imported module has no export named '${name}'`, nameExpression?.span ?? callSpan);
|
|
166
|
+
return anyType;
|
|
167
|
+
}
|
|
168
|
+
if (!isWebComponentConstructor(exported)) {
|
|
169
|
+
context.typeError(`Dynamic export '${name}' is ${describeType(exported)}, not a component`, nameExpression?.span ?? callSpan);
|
|
170
|
+
return anyType;
|
|
171
|
+
}
|
|
172
|
+
const loadingExpression = argumentAt(2);
|
|
173
|
+
if (loadingExpression && !isInvalidType(loadingFallback) && loadingFallback.kind !== "null" && loadingFallback.kind !== "any") {
|
|
174
|
+
if (!isWebComponentType(loadingFallback))
|
|
175
|
+
context.typeError("A lazy loading fallback must be a component", loadingExpression.span);
|
|
176
|
+
else if (loadingFallback.requiredProperties.size > 0)
|
|
177
|
+
context.typeError("A lazy loading fallback cannot require props", loadingExpression.span);
|
|
178
|
+
}
|
|
179
|
+
const failedExpression = argumentAt(3);
|
|
180
|
+
if (failedExpression && !isInvalidType(failedFallback) && failedFallback.kind !== "null" && failedFallback.kind !== "any") {
|
|
181
|
+
if (!isWebComponentType(failedFallback))
|
|
182
|
+
context.typeError("A lazy failure fallback must be a component accepting error: Error", failedExpression.span);
|
|
183
|
+
else {
|
|
184
|
+
const error = failedFallback.properties.get("error");
|
|
185
|
+
if (!error || !context.isAssignable({ kind: "class", name: "Error" }, error) || [...failedFallback.requiredProperties].some((prop) => prop !== "error")) {
|
|
186
|
+
context.typeError("A lazy failure fallback must accept error: Error and require no other props", failedExpression.span);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return exported;
|
|
191
|
+
}
|
|
192
|
+
case "http.request": {
|
|
193
|
+
arity();
|
|
194
|
+
let options = null;
|
|
195
|
+
for (const [index, expected] of intrinsic.parameters.entries()) {
|
|
196
|
+
const actual = inferAt(index, expected);
|
|
197
|
+
if (index === intrinsic.parameters.length - 1 && argumentAt(index))
|
|
198
|
+
options = actual;
|
|
199
|
+
}
|
|
200
|
+
const fields = options?.kind === "object" ? options.fields : null;
|
|
201
|
+
const body = fields?.get("body");
|
|
202
|
+
const expandedBody = body ? context.expandAliases(body) : null;
|
|
203
|
+
const blob = expandedBody?.kind === "named" && expandedBody.name === "Blob";
|
|
204
|
+
if (body && !context.isHttpFormBody(body) && !blob && context.jsonSerializable(body) === false) {
|
|
205
|
+
const optionsExpression = argumentAt(intrinsic.parameters.length - 1);
|
|
206
|
+
const bodyExpression = optionsExpression?.kind === "ObjectExpression"
|
|
207
|
+
? optionsExpression.properties.find((property) => property.kind === "ObjectProperty" && property.name === "body")?.value
|
|
208
|
+
: null;
|
|
209
|
+
context.typeError(`HTTP JSON bodies accept only records, Lists, enums, primitives, and optionals; received ${describeType(body)}`, bodyExpression?.span ?? optionsExpression?.span ?? callSpan);
|
|
210
|
+
}
|
|
211
|
+
return intrinsic.result;
|
|
212
|
+
}
|
|
213
|
+
case "storage.set": {
|
|
214
|
+
arity(2, 3);
|
|
215
|
+
inferAt(0, stringType);
|
|
216
|
+
const value = inferAt(1);
|
|
217
|
+
if (argumentAt(2))
|
|
218
|
+
inferAt(2, numberType);
|
|
219
|
+
const valueExpression = argumentAt(1);
|
|
220
|
+
if (context.jsonSerializable(value) === false && valueExpression) {
|
|
221
|
+
context.typeError(`Storage values accept only records, Lists, enums, primitives, and optionals; received ${describeType(value)}`, valueExpression.span);
|
|
222
|
+
}
|
|
223
|
+
return intrinsic.result;
|
|
224
|
+
}
|
|
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
|
+
case "config.public":
|
|
234
|
+
arity(1, 1);
|
|
235
|
+
return runtimeTypeAt(0);
|
|
236
|
+
case "storage.get": {
|
|
237
|
+
if (!argumentAt(1)) {
|
|
238
|
+
context.typeError(storageReadGuidance("storage.get(key, Type)"), callSpan);
|
|
239
|
+
return unknownType;
|
|
240
|
+
}
|
|
241
|
+
arity(2, 4);
|
|
242
|
+
inferAt(0, stringType);
|
|
243
|
+
const parsed = runtimeTypeAt(1);
|
|
244
|
+
if (argumentAt(3))
|
|
245
|
+
inferAt(3, numberType);
|
|
246
|
+
if (argumentAt(2)) {
|
|
247
|
+
inferAt(2, parsed);
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
return optionalOf(parsed);
|
|
251
|
+
}
|
|
252
|
+
case "storage.databaseGet": {
|
|
253
|
+
if (!argumentAt(1)) {
|
|
254
|
+
context.typeError(storageReadGuidance("database(name).get(key, Type)"), callSpan);
|
|
255
|
+
return { kind: "promise", value: unknownType };
|
|
256
|
+
}
|
|
257
|
+
arity(2, 4);
|
|
258
|
+
inferAt(0, stringType);
|
|
259
|
+
const parsed = runtimeTypeAt(1);
|
|
260
|
+
if (argumentAt(3))
|
|
261
|
+
inferAt(3, numberType);
|
|
262
|
+
if (argumentAt(2)) {
|
|
263
|
+
inferAt(2, parsed);
|
|
264
|
+
return { kind: "promise", value: parsed };
|
|
265
|
+
}
|
|
266
|
+
return { kind: "promise", value: optionalOf(parsed) };
|
|
267
|
+
}
|
|
268
|
+
case "storage.watch": {
|
|
269
|
+
if (!argumentAt(1)) {
|
|
270
|
+
context.typeError(storageReadGuidance("storage.watch(key, Type, callback)"), callSpan);
|
|
271
|
+
return { kind: "function", parameters: [], requiredParameters: 0, result: nullType };
|
|
272
|
+
}
|
|
273
|
+
arity(3, 4);
|
|
274
|
+
inferAt(0, stringType);
|
|
275
|
+
const parsed = runtimeTypeAt(1);
|
|
276
|
+
callbackAt(2, [optionalOf(parsed), optionalOf(parsed)], unknownType);
|
|
277
|
+
if (argumentAt(3))
|
|
278
|
+
inferAt(3, numberType);
|
|
279
|
+
return { kind: "function", parameters: [], requiredParameters: 0, result: nullType };
|
|
280
|
+
}
|
|
281
|
+
case "forms.read": {
|
|
282
|
+
arity(2, 2);
|
|
283
|
+
inferAt(0, { kind: "named", name: "Element" });
|
|
284
|
+
const parsed = runtimeTypeAt(1);
|
|
285
|
+
if (parsed.kind === "any" || parsed.kind === "unknown")
|
|
286
|
+
return parsed;
|
|
287
|
+
const expanded = context.expandAliases(parsed);
|
|
288
|
+
const fields = expanded.kind === "named" ? context.declaredFieldsOf(expanded.identity ?? expanded.name) : null;
|
|
289
|
+
if (!fields) {
|
|
290
|
+
context.typeError("Form reading requires a record declared with 'type Name:'", argumentAt(1)?.span ?? callSpan);
|
|
291
|
+
return parsed;
|
|
292
|
+
}
|
|
293
|
+
const descriptors = [];
|
|
294
|
+
for (const [name, field] of fields) {
|
|
295
|
+
const descriptor = context.formReadField(name, field, argumentAt(1)?.span ?? callSpan);
|
|
296
|
+
if (descriptor)
|
|
297
|
+
descriptors.push(descriptor);
|
|
298
|
+
}
|
|
299
|
+
if (descriptors.length === fields.size)
|
|
300
|
+
context.recordFormRead(callSpan, descriptors);
|
|
301
|
+
return parsed;
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Multi-token shorthand strings on properties with a checked builder
|
|
308
|
+
// equivalent bypass the builder system, so they are rejected with directive
|
|
309
|
+
// guidance that computes the builder call whenever the string decomposes
|
|
310
|
+
// cleanly. Single-token keyword strings and hex color strings stay accepted.
|
|
311
|
+
const lookSpacingFamily = /^(?:margin|padding|inset)/u;
|
|
312
|
+
const lookSpacingProperties = new Set(["borderRadius", "borderWidth"]);
|
|
313
|
+
const lookBorderProperties = new Set(["border", "borderTop", "borderRight", "borderBottom", "borderLeft", "outline"]);
|
|
314
|
+
// The border() builder's guard, the five border-style properties and this
|
|
315
|
+
// shorthand reader are one vocabulary, so they read one table (D57 rule 134).
|
|
316
|
+
const lookBorderStyles = LOOK_BORDER_STYLE_NAMES;
|
|
317
|
+
function lookBuilderToken(token) {
|
|
318
|
+
if (/^[+-]?\d+(?:\.\d+)?$/u.test(token))
|
|
319
|
+
return `${token}px`;
|
|
320
|
+
if (/^[+-]?\d+(?:\.\d+)?[a-z%]+$/iu.test(token))
|
|
321
|
+
return token;
|
|
322
|
+
return `"${token}"`;
|
|
323
|
+
}
|
|
324
|
+
function numericLiteral(expression) {
|
|
325
|
+
if (expression?.kind === "LiteralExpression" && typeof expression.value === "number")
|
|
326
|
+
return expression.value;
|
|
327
|
+
if (expression?.kind === "UnaryExpression" && expression.operator === "-"
|
|
328
|
+
&& expression.operand.kind === "LiteralExpression" && typeof expression.operand.value === "number") {
|
|
329
|
+
return -expression.operand.value;
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
function coreDurationLiteral(expression) {
|
|
334
|
+
if (expression?.kind !== "ExtensionExpression:core:duration")
|
|
335
|
+
return null;
|
|
336
|
+
return expression;
|
|
337
|
+
}
|
|
338
|
+
function lookDurationLiteral(expression) {
|
|
339
|
+
const direct = coreDurationLiteral(expression);
|
|
340
|
+
if (direct) {
|
|
341
|
+
return direct.value * (direct.unit === "s" ? 1000 : 1);
|
|
342
|
+
}
|
|
343
|
+
const operand = expression?.kind === "UnaryExpression" ? coreDurationLiteral(expression.operand) : null;
|
|
344
|
+
if (expression?.kind === "UnaryExpression" && (expression.operator === "+" || expression.operator === "-") && operand) {
|
|
345
|
+
const value = operand.value * (operand.unit === "s" ? 1000 : 1);
|
|
346
|
+
return expression.operator === "-" ? -value : value;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
function lookBorderCall(tokens) {
|
|
351
|
+
let width = null;
|
|
352
|
+
let style = null;
|
|
353
|
+
let color = null;
|
|
354
|
+
for (const token of tokens) {
|
|
355
|
+
if (/^[+-]?\d/u.test(token) && width === null)
|
|
356
|
+
width = lookBuilderToken(token);
|
|
357
|
+
else if (lookBorderStyles.has(token) && style === null)
|
|
358
|
+
style = token;
|
|
359
|
+
else if (color === null && /^#[0-9a-f]{3,8}$/iu.test(token))
|
|
360
|
+
color = `color("${token}")`;
|
|
361
|
+
else if (color === null && /^[a-z]+$/iu.test(token))
|
|
362
|
+
color = `color("${token}")`;
|
|
363
|
+
else
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (width === null || color === null)
|
|
367
|
+
return null;
|
|
368
|
+
const buildArguments = style !== null && style !== "solid" ? `${width}, ${color}, "${style}"` : `${width}, ${color}`;
|
|
369
|
+
return `border(${buildArguments})`;
|
|
370
|
+
}
|
|
371
|
+
function lookShorthandStringGuidance(name, value) {
|
|
372
|
+
if (value.kind !== "LiteralExpression" || typeof value.value !== "string")
|
|
373
|
+
return null;
|
|
374
|
+
const text = value.value.trim();
|
|
375
|
+
if ((name === "gridTemplateColumns" || name === "gridTemplateRows") && text !== "none") {
|
|
376
|
+
return "Use the tracks(...) builder for grid templates; for example, write tracks(240px, minmax(0px, 1fr)) instead of CSS track-list text";
|
|
377
|
+
}
|
|
378
|
+
if (name === "backgroundImage" && /^linear-gradient\s*\(/iu.test(text)) {
|
|
379
|
+
return "Use linearGradient(angle, start, end); for example linearGradient(90deg, color(\"red\"), color(\"blue\")) instead of gradient text";
|
|
380
|
+
}
|
|
381
|
+
if (!/\s/u.test(text))
|
|
382
|
+
return null;
|
|
383
|
+
const tokens = text.split(/\s+/u);
|
|
384
|
+
if (lookSpacingFamily.test(name) || lookSpacingProperties.has(name)) {
|
|
385
|
+
return `Use 'spacing(${tokens.map(lookBuilderToken).join(", ")})'; Look multi-value shorthand is written with the spacing builder`;
|
|
386
|
+
}
|
|
387
|
+
if (lookBorderProperties.has(name)) {
|
|
388
|
+
const call = lookBorderCall(tokens);
|
|
389
|
+
return call
|
|
390
|
+
? `Use '${call}'; Look border shorthand is written with the border builder`
|
|
391
|
+
: "Use the 'border(width, color, style)' builder; multi-part border strings bypass the checked Look system";
|
|
392
|
+
}
|
|
393
|
+
if (name === "boxShadow") {
|
|
394
|
+
return "Use the 'shadow(x, y, blur, color)' builder; multi-part shadow strings bypass the checked Look system";
|
|
395
|
+
}
|
|
396
|
+
if (name === "transition") {
|
|
397
|
+
const [property, duration, easing, delay] = tokens;
|
|
398
|
+
const durations = /^[+-]?\d+(?:\.\d+)?(?:ms|s)$/u;
|
|
399
|
+
if (property && duration && durations.test(duration) && tokens.length <= 4
|
|
400
|
+
&& (easing === undefined || /^[a-z-]+$/iu.test(easing))
|
|
401
|
+
&& (delay === undefined || durations.test(delay))) {
|
|
402
|
+
const buildArguments = [`"${property}"`, duration, ...easing ? [`"${easing}"`] : [], ...delay ? [delay] : []];
|
|
403
|
+
return `Use 'transition(${buildArguments.join(", ")})'; Look transition shorthand is written with the transition builder`;
|
|
404
|
+
}
|
|
405
|
+
return "Use the 'transition(property, duration, easing, delay)' builder; multi-part transition strings bypass the checked Look system";
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const lookCssWideKeywords = new Set(LOOK_CSS_WIDE_KEYWORDS);
|
|
410
|
+
const lookSharedMetricKeywords = ["auto", "none", "normal", "min-content", "max-content", "fit-content", "stretch"];
|
|
411
|
+
const lookMetricKeywords = new Set([...lookCssWideKeywords, ...lookSharedMetricKeywords]);
|
|
412
|
+
/**
|
|
413
|
+
* D73 rule 187: what a refusal says the property's *other* half is, so the
|
|
414
|
+
* keyword list it goes on to print reads as the keyword half of a real value
|
|
415
|
+
* space rather than as the whole of it.
|
|
416
|
+
*/
|
|
417
|
+
function lookVocabularyLead(kind) {
|
|
418
|
+
switch (kind) {
|
|
419
|
+
case "border": return "use the border(width, color, style) builder, or one of";
|
|
420
|
+
case "shadow": return "use the shadow(x, y, blur, color) builder, or one of";
|
|
421
|
+
case "angle": return "write an angle such as 45deg or 0.25turn, or one of";
|
|
422
|
+
case "duration": return "write a duration such as 200ms or 0.3s, or one of";
|
|
423
|
+
case "line-height": return "write a number or a length such as 1.5 or 24px, or one of";
|
|
424
|
+
case "number": return "write a number, or one of";
|
|
425
|
+
case "number-keyword": return "write a number, or one of";
|
|
426
|
+
case "track": return "use the tracks(...) builder, or one of";
|
|
427
|
+
case "transition": return "use the transition(property, duration, easing, delay) builder, or one of";
|
|
428
|
+
default: return "write one of";
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const lookColorKeywords = new Set([
|
|
432
|
+
...lookCssWideKeywords, "transparent", "currentColor", "black", "silver", "gray", "white", "maroon", "red", "purple",
|
|
433
|
+
"fuchsia", "green", "lime", "olive", "yellow", "navy", "blue", "teal", "aqua", "orange", "aliceblue", "rebeccapurple",
|
|
434
|
+
]);
|
|
435
|
+
/**
|
|
436
|
+
* D67 rule 174 — what a rejected Look string could have been.
|
|
437
|
+
*
|
|
438
|
+
* The message this replaces said "use one of the closed `name` keywords" and
|
|
439
|
+
* stopped there, so the answer to "which ones?" lived only in the source of the
|
|
440
|
+
* table. The evidence that this is not a hypothetical reader problem is that
|
|
441
|
+
* the usage tour — written by someone who knew the design intent — shipped
|
|
442
|
+
* twelve values no property had, and this diagnostic could not have told him.
|
|
443
|
+
*
|
|
444
|
+
* A near miss gets the one spelling meant, because naming the single correct
|
|
445
|
+
* word is the strongest form of the promise. Otherwise a set small enough to
|
|
446
|
+
* read is written out whole, and a larger one says what it holds. The CSS-wide
|
|
447
|
+
* keywords join the search either way, so a misspelled `inherit` is caught the
|
|
448
|
+
* same as a misspelled `groove`.
|
|
449
|
+
*/
|
|
450
|
+
function lookVocabularyGuidance(property, written, own, lead) {
|
|
451
|
+
const vocabulary = [...new Set([...own, ...LOOK_CSS_WIDE_KEYWORDS])];
|
|
452
|
+
const nearest = nearestLookName(written, vocabulary);
|
|
453
|
+
if (nearest !== null)
|
|
454
|
+
return { text: `did you mean '${nearest}'?`, named: true };
|
|
455
|
+
const shape = LOOK_LARGE_KEYWORD_SETS.get(property);
|
|
456
|
+
return {
|
|
457
|
+
text: shape === undefined
|
|
458
|
+
? `${lead} ${vocabulary.join(", ")}`
|
|
459
|
+
: `${lead} ${shape}, and none of them is spelled close to '${written}'`,
|
|
460
|
+
named: false,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function literalLookStrings(expression) {
|
|
464
|
+
if (expression.kind === "LiteralExpression")
|
|
465
|
+
return typeof expression.value === "string" ? [expression.value] : [];
|
|
466
|
+
if (expression.kind === "ConditionalExpression") {
|
|
467
|
+
const thenValues = literalLookStrings(expression.thenValue);
|
|
468
|
+
const elseValues = literalLookStrings(expression.elseValue);
|
|
469
|
+
return thenValues && elseValues ? [...thenValues, ...elseValues] : null;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
function isViewportComparison(expression) {
|
|
474
|
+
if (expression.kind !== "BinaryExpression" || !["<", "<=", ">", ">="].includes(expression.operator))
|
|
475
|
+
return false;
|
|
476
|
+
if (expression.left.kind !== "MemberExpression" || expression.left.object.kind !== "IdentifierExpression" || expression.left.object.name !== "viewport")
|
|
477
|
+
return false;
|
|
478
|
+
if (expression.left.property !== "width" && expression.left.property !== "height")
|
|
479
|
+
return false;
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
// LOK-I2: '720px >= viewport.width' means the same breakpoint as
|
|
483
|
+
// 'viewport.width <= 720px', but only the viewport-on-the-left spelling lowers
|
|
484
|
+
// to a media query. The flipped spelling is recognized so it teaches the
|
|
485
|
+
// supported order instead of reporting the subject as an unknown name.
|
|
486
|
+
const flippedComparisons = new Map([["<", ">"], ["<=", ">="], [">", "<"], [">=", "<="]]);
|
|
487
|
+
function flippedViewportComparison(expression) {
|
|
488
|
+
if (expression.kind !== "BinaryExpression" || !flippedComparisons.has(expression.operator))
|
|
489
|
+
return null;
|
|
490
|
+
const subject = mediaSubjectShape(expression.right);
|
|
491
|
+
if (subject?.subject !== "viewport" || (subject.feature !== "width" && subject.feature !== "height"))
|
|
492
|
+
return null;
|
|
493
|
+
return { property: subject.feature, operator: flippedComparisons.get(expression.operator), threshold: expression.left };
|
|
494
|
+
}
|
|
495
|
+
// 'scheme.dark'/'scheme.light' and 'motion.reduced' are Look condition subjects
|
|
496
|
+
// that lower to prefers-color-scheme and prefers-reduced-motion media queries,
|
|
497
|
+
// mirroring the viewport.* media atoms.
|
|
498
|
+
function isSchemeCondition(expression) {
|
|
499
|
+
if (expression.kind !== "MemberExpression" || expression.object.kind !== "IdentifierExpression")
|
|
500
|
+
return false;
|
|
501
|
+
const subject = expression.object.name;
|
|
502
|
+
if (subject === "viewport")
|
|
503
|
+
return false;
|
|
504
|
+
return (LOOK_MEDIA_SUBJECTS.get(subject)?.has(expression.property)) ?? false;
|
|
505
|
+
}
|
|
506
|
+
/** The subject name of a `subject.feature` shape, whether or not it is a Look subject. */
|
|
507
|
+
function mediaSubjectShape(expression) {
|
|
508
|
+
if (expression.kind !== "MemberExpression" || expression.optional || expression.object.kind !== "IdentifierExpression")
|
|
509
|
+
return null;
|
|
510
|
+
return { subject: expression.object.name, feature: expression.property };
|
|
511
|
+
}
|
|
512
|
+
function lookMediaVocabulary() {
|
|
513
|
+
return [...LOOK_MEDIA_SUBJECTS].flatMap(([subject, features]) => [...features].map((feature) => `${subject}.${feature}`)).join(", ");
|
|
514
|
+
}
|
|
515
|
+
/** A readable rendering of a breakpoint threshold for diagnostic guidance. */
|
|
516
|
+
function lookSourceOf(expression) {
|
|
517
|
+
if (isWebUnit(expression))
|
|
518
|
+
return expression.raw;
|
|
519
|
+
if (expression.kind === "IdentifierExpression")
|
|
520
|
+
return expression.name;
|
|
521
|
+
if (expression.kind === "LiteralExpression")
|
|
522
|
+
return expression.raw;
|
|
523
|
+
if (expression.kind === "MemberExpression")
|
|
524
|
+
return `${lookSourceOf(expression.object)}.${expression.property}`;
|
|
525
|
+
return "breakpoint";
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* A canonical rendering of one Look condition, so two sibling blocks that lower
|
|
529
|
+
* to the same selector and media query share a duplicate-detection scope.
|
|
530
|
+
*/
|
|
531
|
+
function lookConditionSignature(expression) {
|
|
532
|
+
if (isWebExpression(expression) && expression.kind === "ExtensionExpression:web:look-hook")
|
|
533
|
+
return `@${expression.name}`;
|
|
534
|
+
if (expression.kind === "UnaryExpression" && expression.operator === "not")
|
|
535
|
+
return `!(${lookConditionSignature(expression.operand)})`;
|
|
536
|
+
if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")) {
|
|
537
|
+
return `(${lookConditionSignature(expression.left)}${expression.operator}${lookConditionSignature(expression.right)})`;
|
|
538
|
+
}
|
|
539
|
+
if (expression.kind === "BinaryExpression")
|
|
540
|
+
return `(${lookConditionSignature(expression.left)}${expression.operator}${lookConditionSignature(expression.right)})`;
|
|
541
|
+
if (expression.kind === "MemberExpression")
|
|
542
|
+
return `${lookConditionSignature(expression.object)}.${expression.property}`;
|
|
543
|
+
if (expression.kind === "IdentifierExpression")
|
|
544
|
+
return `id:${expression.name}`;
|
|
545
|
+
if (isWebUnit(expression))
|
|
546
|
+
return `${expression.value}${expression.unit}`;
|
|
547
|
+
if (expression.kind === "LiteralExpression")
|
|
548
|
+
return `lit:${expression.raw}`;
|
|
549
|
+
return `span:${expression.span.start}`;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Every name in a module or component body whose read is reactive but whose
|
|
553
|
+
* binding is not itself a state/prop reference: a computed accessor, a resource
|
|
554
|
+
* handle, an action handle. A Look literal that reads one of these freezes it
|
|
555
|
+
* exactly as it freezes a state read (LOK-D1).
|
|
556
|
+
*/
|
|
557
|
+
function collectDerivedReactiveNames(program) {
|
|
558
|
+
const names = new Set();
|
|
559
|
+
const record = (statements) => {
|
|
560
|
+
for (const statement of statements) {
|
|
561
|
+
if (statement.kind === "VariableDeclaration" && statement.pattern.kind === "NameBindingPattern"
|
|
562
|
+
&& statement.initializer.kind === "CallExpression" && statement.initializer.callee.kind === "IdentifierExpression"
|
|
563
|
+
&& (statement.initializer.callee.name === "cached" || statement.initializer.callee.name === "computed")) {
|
|
564
|
+
names.add(statement.pattern.name);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!isWebStatement(statement))
|
|
568
|
+
continue;
|
|
569
|
+
if (statement.kind === "ExtensionStatement:web:resource" || statement.kind === "ExtensionStatement:web:action")
|
|
570
|
+
names.add(statement.name);
|
|
571
|
+
if (statement.kind === "ExtensionStatement:web:component")
|
|
572
|
+
record(statement.body);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
record(program.body);
|
|
576
|
+
return names;
|
|
577
|
+
}
|
|
578
|
+
/** Local names bound to a velar/look builder, including aliased imports. */
|
|
579
|
+
function collectLookBuilderNames(program) {
|
|
580
|
+
const names = new Map();
|
|
581
|
+
for (const statement of program.body) {
|
|
582
|
+
if (statement.kind !== "ImportDeclaration" || statement.source !== "velar/look" || statement.javascript)
|
|
583
|
+
continue;
|
|
584
|
+
for (const specifier of statement.specifiers) {
|
|
585
|
+
if (specifier.namespace || !LOOK_BUILDERS.has(specifier.imported))
|
|
586
|
+
continue;
|
|
587
|
+
names.set(specifier.local, specifier.imported);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return names;
|
|
591
|
+
}
|
|
592
|
+
function lookConditionTermCount(expression, negated = false) {
|
|
593
|
+
if (expression.kind === "UnaryExpression" && expression.operator === "not")
|
|
594
|
+
return lookConditionTermCount(expression.operand, !negated);
|
|
595
|
+
if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")) {
|
|
596
|
+
const conjunction = (expression.operator === "and") !== negated;
|
|
597
|
+
const left = lookConditionTermCount(expression.left, negated);
|
|
598
|
+
const right = lookConditionTermCount(expression.right, negated);
|
|
599
|
+
const total = conjunction ? left * right : left + right;
|
|
600
|
+
return Math.min(LOOK_CONDITION_TERM_LIMIT + 1, total);
|
|
601
|
+
}
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
function firstRelativeCssAssetAddress(source) {
|
|
605
|
+
for (const token of cssTokens(source)) {
|
|
606
|
+
if (token.kind !== "url" && token.kind !== "asset-address")
|
|
607
|
+
continue;
|
|
608
|
+
// A URL parser drops leading and trailing spaces, and an empty url() names
|
|
609
|
+
// the document itself rather than an asset.
|
|
610
|
+
const value = token.value.trim();
|
|
611
|
+
if (value === "")
|
|
612
|
+
continue;
|
|
613
|
+
if (value.startsWith("/") || value.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(value))
|
|
614
|
+
continue;
|
|
615
|
+
return { value, syntax: token.kind === "url" ? "url" : token.syntax };
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
function isLookNumericType(type) {
|
|
620
|
+
return type.kind === "named" && LOOK_NUMERIC_TYPE_NAMES.has(type.name);
|
|
621
|
+
}
|
|
622
|
+
/** True when a property's declared domain includes a spelled visual unit type. */
|
|
623
|
+
function mentionsLookUnitType(type) {
|
|
624
|
+
if (type.kind === "named")
|
|
625
|
+
return LOOK_NUMERIC_TYPE_NAMES.has(type.name) || type.name === "Spacing" || type.name === "TrackList" || type.name === "Track";
|
|
626
|
+
if (type.kind === "union")
|
|
627
|
+
return type.members.some(mentionsLookUnitType);
|
|
628
|
+
if (type.kind === "optional")
|
|
629
|
+
return mentionsLookUnitType(type.inner);
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
function lookLiteralZero(expression) {
|
|
633
|
+
if (expression.kind === "LiteralExpression")
|
|
634
|
+
return expression.value === 0;
|
|
635
|
+
if (expression.kind === "UnaryExpression" && (expression.operator === "-" || expression.operator === "+"))
|
|
636
|
+
return lookLiteralZero(expression.operand);
|
|
637
|
+
return isWebUnit(expression) && expression.value === 0;
|
|
638
|
+
}
|
|
639
|
+
function lookAdditiveType(left, right) {
|
|
640
|
+
if (!isLookNumericType(left) || !isLookNumericType(right))
|
|
641
|
+
return null;
|
|
642
|
+
if (semanticTypeIdentity(left) === semanticTypeIdentity(right))
|
|
643
|
+
return left;
|
|
644
|
+
const lengthPercentageNames = new Set(["Length", "Percentage", "LengthPercentage"]);
|
|
645
|
+
if (left.kind === "named" && right.kind === "named"
|
|
646
|
+
&& lengthPercentageNames.has(left.name) && lengthPercentageNames.has(right.name))
|
|
647
|
+
return lookLengthPercentage;
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
function containsCssImport(source) {
|
|
651
|
+
for (const token of cssTokens(source)) {
|
|
652
|
+
if (token.kind === "at-keyword" && token.name.toLowerCase() === "import")
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
function checkRouteComponent(type, sourceSpan, subject, context) {
|
|
658
|
+
if (isInvalidType(type))
|
|
659
|
+
return;
|
|
660
|
+
if (!isWebComponentType(type)) {
|
|
661
|
+
if (type.kind !== "any")
|
|
662
|
+
context.typeError(`${subject} requires a component, received ${describeType(type)}`, sourceSpan);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const unsupported = [...type.requiredProperties].filter((name) => name !== "route");
|
|
666
|
+
if (unsupported.length > 0)
|
|
667
|
+
context.typeError(`${subject} component cannot require props other than route: ${unsupported.join(", ")}`, sourceSpan);
|
|
668
|
+
const routeProp = type.properties.get("route");
|
|
669
|
+
if (routeProp && !context.isAssignable({ kind: "named", name: "RouteContext", identity: routeContextIdentity }, routeProp))
|
|
670
|
+
context.typeError(`${subject} component's route prop must accept RouteContext, received ${describeType(routeProp)}`, sourceSpan);
|
|
671
|
+
}
|
|
672
|
+
function checkRoutePath(path, sourceSpan, context) {
|
|
673
|
+
if (!path.startsWith("/"))
|
|
674
|
+
context.typeError("A route path must start with '/'", sourceSpan);
|
|
675
|
+
if (path.includes("?") || path.includes("#"))
|
|
676
|
+
context.typeError("A route path describes only a pathname; read query and hash from RouteContext", sourceSpan);
|
|
677
|
+
if (path.includes("\\"))
|
|
678
|
+
context.typeError("A route path cannot contain a backslash", sourceSpan);
|
|
679
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
680
|
+
context.typeError("A route path cannot end with '/'; matching already accepts one trailing slash", sourceSpan);
|
|
681
|
+
const segments = path.split("/").slice(1);
|
|
682
|
+
const parameters = new Set();
|
|
683
|
+
for (const [index, segment] of segments.entries()) {
|
|
684
|
+
if (segment.length === 0 && path !== "/")
|
|
685
|
+
context.typeError("A route path cannot contain an empty segment", sourceSpan);
|
|
686
|
+
if (segment === "*") {
|
|
687
|
+
if (index !== segments.length - 1)
|
|
688
|
+
context.typeError("A route wildcard must be the final segment", sourceSpan);
|
|
689
|
+
if (parameters.has("wildcard"))
|
|
690
|
+
context.typeError("A route parameter named 'wildcard' conflicts with the '*' capture", sourceSpan);
|
|
691
|
+
parameters.add("wildcard");
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (segment.includes("*"))
|
|
695
|
+
context.typeError("A route wildcard must occupy its whole final segment", sourceSpan);
|
|
696
|
+
if (!segment.startsWith(":"))
|
|
697
|
+
continue;
|
|
698
|
+
const name = segment.slice(1);
|
|
699
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))
|
|
700
|
+
context.typeError("A route parameter requires a valid name", sourceSpan);
|
|
701
|
+
else if (parameters.has(name))
|
|
702
|
+
context.typeError(`Route parameter '${name}' is repeated`, sourceSpan);
|
|
703
|
+
parameters.add(name);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function containsPromise(type) {
|
|
707
|
+
if (type.kind === "promise")
|
|
708
|
+
return true;
|
|
709
|
+
if (type.kind === "optional")
|
|
710
|
+
return containsPromise(type.inner);
|
|
711
|
+
if (type.kind === "list" || type.kind === "set")
|
|
712
|
+
return containsPromise(type.element);
|
|
713
|
+
if (type.kind === "map")
|
|
714
|
+
return containsPromise(type.key) || containsPromise(type.value);
|
|
715
|
+
if (type.kind === "record")
|
|
716
|
+
return containsPromise(type.value);
|
|
717
|
+
if (type.kind === "union")
|
|
718
|
+
return type.members.some(containsPromise);
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
function hasAccessibleJsxContent(expression) {
|
|
722
|
+
return expression.children.some((child) => {
|
|
723
|
+
if (child.kind === "JSXText")
|
|
724
|
+
return child.value.trim().length > 0;
|
|
725
|
+
if (child.kind === "JSXExpressionChild")
|
|
726
|
+
return true;
|
|
727
|
+
return hasAccessibleJsxContent(child) || child.tag.length > 0;
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
function hasAccessibleSvgName(expression) {
|
|
731
|
+
const named = expression.attributes.some((attribute) => {
|
|
732
|
+
if (attribute.name !== "aria-label" && attribute.name !== "aria-labelledby")
|
|
733
|
+
return false;
|
|
734
|
+
if (attribute.value === null)
|
|
735
|
+
return false;
|
|
736
|
+
return typeof attribute.value !== "string" || attribute.value.trim().length > 0;
|
|
737
|
+
});
|
|
738
|
+
if (named)
|
|
739
|
+
return true;
|
|
740
|
+
const hidden = expression.attributes.find((attribute) => attribute.name === "aria-hidden");
|
|
741
|
+
if (hidden?.value === "true" || (hidden?.value && typeof hidden.value !== "string"
|
|
742
|
+
&& hidden.value.kind === "LiteralExpression" && hidden.value.value === true))
|
|
743
|
+
return true;
|
|
744
|
+
return expression.children.some((child) => child.kind === "ExtensionExpression:web:jsx"
|
|
745
|
+
&& child.tag === "title" && hasAccessibleJsxContent(child));
|
|
746
|
+
}
|
|
747
|
+
export class VelarWebAnalyzer extends Analyzer {
|
|
748
|
+
componentStates = null;
|
|
749
|
+
mountedDepth = 0;
|
|
750
|
+
cleanupDepth = 0;
|
|
751
|
+
/** D51 (audit 12): a component `watch` body runs on a change and ends, exactly as a module `watch` body does. */
|
|
752
|
+
watchBodyDepth = 0;
|
|
753
|
+
synchronousReactiveDepth = 0;
|
|
754
|
+
jsxDepth = 0;
|
|
755
|
+
resources;
|
|
756
|
+
unsafeCssImports = new Set();
|
|
757
|
+
probedOperandTypes = new Map();
|
|
758
|
+
importedLookStaticValues;
|
|
759
|
+
lookStaticValues = new Map();
|
|
760
|
+
lookEntryScopes = new Map();
|
|
761
|
+
derivedReactiveNames = new Set();
|
|
762
|
+
checkedBuilderCalls = new Set();
|
|
763
|
+
staticJsxKeys = [];
|
|
764
|
+
honoredJsxKeys = new Set();
|
|
765
|
+
reportedJsxKeys = new Set();
|
|
766
|
+
lookBuilderNames = new Map();
|
|
767
|
+
lookLiteralDepth = 0;
|
|
768
|
+
/**
|
|
769
|
+
* D71 rule 182: the declaration spans of every `computed` binding in scope.
|
|
770
|
+
* A binding's span survives narrowing, so it identifies the declaration a
|
|
771
|
+
* name resolves to even where a narrowed copy answers the lookup.
|
|
772
|
+
*/
|
|
773
|
+
computedBindingSpans = new Set();
|
|
774
|
+
/** Local names bound to an imported `export computed`, from the Web interface. */
|
|
775
|
+
importedComputedNames;
|
|
776
|
+
/** The resolved spans of those imports, so a local shadow of the name is not one. */
|
|
777
|
+
importedComputedSpans = new Set();
|
|
778
|
+
/** D71 migration state: retired `const x = computed(...)` sites, their reads, and every other `computed` reference. */
|
|
779
|
+
retiredAccessorDeclarations = new Map();
|
|
780
|
+
retiredAccessorReads = new Map();
|
|
781
|
+
retiredComputedReferences = new Map();
|
|
782
|
+
migratedComputedCallees = new Set();
|
|
783
|
+
/** Callee span identity -> call span, for every zero-argument call of a plain name. */
|
|
784
|
+
plainCallSpans = new Map();
|
|
785
|
+
/** D57 rule 138: `velar/web-test` is legal only where the browser runner looks. */
|
|
786
|
+
webModulePath;
|
|
787
|
+
/** D74: only props whose authors wrote a readonly contract receive prop-specific guidance. */
|
|
788
|
+
explicitReadonlyPropBindings = new Map();
|
|
789
|
+
constructor(context = {}, extensions = []) {
|
|
790
|
+
super(context, extensions);
|
|
791
|
+
this.webModulePath = context.path ?? null;
|
|
792
|
+
this.resources = context.resources ?? new Map();
|
|
793
|
+
const webImports = [...(context.extensionImports?.get("@velarscript/web") ?? [])];
|
|
794
|
+
this.importedLookStaticValues = new Map(webImports.filter((entry) => isLookStaticValue(entry[1])));
|
|
795
|
+
this.importedComputedNames = new Set(webImports.filter(([, value]) => isWebComputedExport(value)).map(([name]) => name));
|
|
796
|
+
}
|
|
797
|
+
analyze(program) {
|
|
798
|
+
this.lookStaticValues = collectLookStaticValues(program, this.importedLookStaticValues);
|
|
799
|
+
this.lookBuilderNames = collectLookBuilderNames(program);
|
|
800
|
+
for (const name of collectDerivedReactiveNames(program))
|
|
801
|
+
this.derivedReactiveNames.add(name);
|
|
802
|
+
this.reportBrowserTestImports(program);
|
|
803
|
+
this.rejectWebOwnedTypeNames(program);
|
|
804
|
+
super.analyze(program);
|
|
805
|
+
this.reportStaticJsxKeys();
|
|
806
|
+
this.reportRetiredComputedFunction();
|
|
807
|
+
return this.diagnostics;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* D57 rule 138: `velar/web-test` only has a runtime under `velar test
|
|
811
|
+
* --browser`, so an import of it anywhere else compiles a call that cannot
|
|
812
|
+
* succeed. D51 rule 109 puts the refusal at the declaration rather than at the
|
|
813
|
+
* eventual use, so the error lands on the `import` line — including the
|
|
814
|
+
* JavaScript-bridge and re-export spellings, which reach the same runtime.
|
|
815
|
+
*/
|
|
816
|
+
reportBrowserTestImports(program) {
|
|
817
|
+
if ((this.webModulePath ?? "").endsWith(BROWSER_TEST_SOURCE_SUFFIX))
|
|
818
|
+
return;
|
|
819
|
+
for (const statement of program.body) {
|
|
820
|
+
if (statement.kind !== "ImportDeclaration" && statement.kind !== "ReExportDeclaration")
|
|
821
|
+
continue;
|
|
822
|
+
if (statement.source !== BROWSER_TEST_MODULE)
|
|
823
|
+
continue;
|
|
824
|
+
this.diagnostics.push(diagnostic("VEL5062", browserTestImportGuidance(), statement.sourceSpan));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* D72 rule 186: a Web module publishes its own type names, and a user
|
|
829
|
+
* declaration of one used to be accepted at the declaration and then lose at
|
|
830
|
+
* every use — `type Event:` compiled, and `describe({kind: "charge"})` was
|
|
831
|
+
* told it could not assign to `Event`, naming a type the author had just
|
|
832
|
+
* written. D51 rule 109 already settled where that refusal belongs: at the
|
|
833
|
+
* declaration, which is the only place a rename is cheap.
|
|
834
|
+
*
|
|
835
|
+
* The names come from the extension's own published table, so adding a type
|
|
836
|
+
* to `WEB_OWNED_TYPE_NAMES` extends this protection with it. The last time
|
|
837
|
+
* this family was repaired by listing names instead of deriving them, the
|
|
838
|
+
* list drifted; D57 rule 135 is the same repair on the Core roster.
|
|
839
|
+
*/
|
|
840
|
+
rejectWebOwnedTypeNames(program) {
|
|
841
|
+
const reject = (name, errorSpan, noun) => {
|
|
842
|
+
if (!WEB_OWNED_TYPE_NAMES.has(name))
|
|
843
|
+
return;
|
|
844
|
+
this.diagnostics.push(diagnostic("VEL5065", `'${name}' is a Web type name, so it cannot also name ${/^[aeiou]/iu.test(noun) ? "an" : "a"} ${noun}; every use of it in a Web module resolves to the built-in. Rename this declaration`, errorSpan));
|
|
845
|
+
};
|
|
846
|
+
for (const statement of program.body) {
|
|
847
|
+
switch (statement.kind) {
|
|
848
|
+
case "TypeDeclaration":
|
|
849
|
+
case "TypeAliasDeclaration":
|
|
850
|
+
reject(statement.name, statement.span, "type");
|
|
851
|
+
break;
|
|
852
|
+
case "ClassDeclaration":
|
|
853
|
+
reject(statement.name, statement.span, "class");
|
|
854
|
+
break;
|
|
855
|
+
case "EnumDeclaration":
|
|
856
|
+
reject(statement.name, statement.span, "enum");
|
|
857
|
+
break;
|
|
858
|
+
case "ExternModuleDeclaration":
|
|
859
|
+
for (const declaration of statement.classes)
|
|
860
|
+
reject(declaration.name, declaration.span, "extern class");
|
|
861
|
+
break;
|
|
862
|
+
case "ImportDeclaration":
|
|
863
|
+
// A standard-module import of the name under itself *is* the built-in
|
|
864
|
+
// — `import {Color, Length} from "velar/look"` is how a module names
|
|
865
|
+
// the published types — so only a binding that would make the name
|
|
866
|
+
// mean something else is refused.
|
|
867
|
+
if (statement.source.startsWith("velar/")) {
|
|
868
|
+
for (const specifier of statement.specifiers) {
|
|
869
|
+
if (specifier.local !== specifier.imported)
|
|
870
|
+
reject(specifier.local, statement.span, "import alias");
|
|
871
|
+
}
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
for (const specifier of statement.specifiers) {
|
|
875
|
+
reject(specifier.local, statement.span, specifier.local === specifier.imported ? "imported name" : "import alias");
|
|
876
|
+
}
|
|
877
|
+
break;
|
|
878
|
+
default:
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* WEB-C1: charter §14 promises that a key outside a keyed shape is a
|
|
885
|
+
* diagnostic rather than a silent no-op. Interpolated positions report while
|
|
886
|
+
* their interpolation is walked; static positions are collected during JSX
|
|
887
|
+
* inference and reported once every keyed interpolation is known.
|
|
888
|
+
*/
|
|
889
|
+
reportStaticJsxKeys() {
|
|
890
|
+
for (const { element, attribute } of this.staticJsxKeys) {
|
|
891
|
+
if (this.honoredJsxKeys.has(element) || this.reportedJsxKeys.has(element))
|
|
892
|
+
continue;
|
|
893
|
+
this.diagnostics.push(diagnostic("VEL5050", `This JSX key has no effect: '<${element.tag}>' is rendered in a fixed position, and keys reuse children by identity only inside 'items.map(item => <Row key={item.id} />)' — remove the key, or render this element from a keyed .map()`, attribute.span));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
predeclareExtensionStatement(statement) {
|
|
897
|
+
if (!isWebStatement(statement))
|
|
898
|
+
return false;
|
|
899
|
+
if (statement.kind === "ExtensionStatement:web:unsafe-css")
|
|
900
|
+
return true;
|
|
901
|
+
if (statement.kind !== "ExtensionStatement:web:component")
|
|
902
|
+
return false;
|
|
903
|
+
this.declareBinding(statement.name, false, this.componentType(statement), statement.span);
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
analyzeExtensionStatement(statement) {
|
|
907
|
+
// Two core statement shapes are answered here before the core sees them:
|
|
908
|
+
// a write to a derived name, which the const message would describe wrongly,
|
|
909
|
+
// and a `const x = computed(...)` declaration, whose migration needs the
|
|
910
|
+
// declaration recorded before its reads are walked.
|
|
911
|
+
if (statement.kind === "AssignmentStatement" && this.rejectComputedAssignment(statement))
|
|
912
|
+
return true;
|
|
913
|
+
if (statement.kind === "VariableDeclaration") {
|
|
914
|
+
this.recordRetiredAccessorDeclaration(statement);
|
|
915
|
+
this.reportExportedCachedContract(statement);
|
|
916
|
+
}
|
|
917
|
+
if (!isWebStatement(statement))
|
|
918
|
+
return false;
|
|
919
|
+
switch (statement.kind) {
|
|
920
|
+
case "ExtensionStatement:web:component":
|
|
921
|
+
// A component body renders after module evaluation, so its reads are
|
|
922
|
+
// deferred for the module-initialization-cycle classification even
|
|
923
|
+
// though component analysis itself runs without a function frame.
|
|
924
|
+
this.deferredExecutionDepth += 1;
|
|
925
|
+
try {
|
|
926
|
+
this.analyzeComponent(statement);
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
this.deferredExecutionDepth -= 1;
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
case "ExtensionStatement:web:state": {
|
|
933
|
+
const annotationValid = statement.type ? this.validateTypeReference(statement.type) : true;
|
|
934
|
+
const annotationContext = statement.type ? this.resolveValidatedAnnotation(statement.type) : null;
|
|
935
|
+
const actual = this.inferExpression(statement.initializer, annotationContext ?? unknownType);
|
|
936
|
+
const declared = annotationContext ?? actual;
|
|
937
|
+
if (annotationValid)
|
|
938
|
+
this.requireAssignable(actual, declared, statement.initializer.span);
|
|
939
|
+
this.declareBinding(statement.name, true, declared, statement.span);
|
|
940
|
+
this.markDeclaredBindingReactive(statement.name, "state");
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
case "ExtensionStatement:web:computed":
|
|
944
|
+
this.analyzeComputedDeclaration(statement);
|
|
945
|
+
return true;
|
|
946
|
+
case "ExtensionStatement:web:resource":
|
|
947
|
+
this.diagnostics.push(diagnostic("VEL3012", "'resource' is only valid at component scope; a module-scope async operation belongs in a module 'action'", statement.span));
|
|
948
|
+
this.analyzeResourceDeclaration(statement);
|
|
949
|
+
return true;
|
|
950
|
+
case "ExtensionStatement:web:action":
|
|
951
|
+
// A module-scope action behaves exactly like a component action —
|
|
952
|
+
// reactive pending/error fields with rejection semantics preserved —
|
|
953
|
+
// but its lifetime is the module, so it never registers disposal.
|
|
954
|
+
if (!this.isTopLevelScope()) {
|
|
955
|
+
this.diagnostics.push(diagnostic("VEL3013", "'action' is only valid at module or component scope", statement.span));
|
|
956
|
+
}
|
|
957
|
+
this.analyzeActionDeclaration(statement);
|
|
958
|
+
return true;
|
|
959
|
+
case "ExtensionStatement:web:watch":
|
|
960
|
+
if (!this.isTopLevelScope()) {
|
|
961
|
+
this.diagnostics.push(diagnostic("VEL3010", "'watch' is only valid at module or component scope", statement.span));
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
this.flowFrameDepth += 1;
|
|
965
|
+
this.synchronousReactiveDepth += 1;
|
|
966
|
+
{
|
|
967
|
+
// The watched expression evaluates while the module initializes;
|
|
968
|
+
// the body only runs on a later change, so its reads are deferred
|
|
969
|
+
// for the module-initialization-cycle classification.
|
|
970
|
+
const watched = this.inferExpression(statement.expression);
|
|
971
|
+
this.rejectFrozenWatchSubject(statement.expression, watched);
|
|
972
|
+
this.enterScope();
|
|
973
|
+
if (statement.currentName)
|
|
974
|
+
this.declareBinding(statement.currentName, false, watched, statement.span);
|
|
975
|
+
if (statement.previousName)
|
|
976
|
+
this.declareBinding(statement.previousName, false, watched, statement.span);
|
|
977
|
+
this.deferredExecutionDepth += 1;
|
|
978
|
+
try {
|
|
979
|
+
this.analyzeStatements(statement.body);
|
|
980
|
+
}
|
|
981
|
+
finally {
|
|
982
|
+
this.deferredExecutionDepth -= 1;
|
|
983
|
+
}
|
|
984
|
+
this.exitScope();
|
|
985
|
+
}
|
|
986
|
+
this.synchronousReactiveDepth -= 1;
|
|
987
|
+
this.flowFrameDepth -= 1;
|
|
988
|
+
return true;
|
|
989
|
+
case "ExtensionStatement:web:unsafe-css": {
|
|
990
|
+
// LOK-D2 / D53: unsafe CSS is a module-level ordering declaration,
|
|
991
|
+
// whether its source is an external resource or an inline raw block.
|
|
992
|
+
// Nested inside a component or a function it used to pass every check,
|
|
993
|
+
// build, and then appear in no output at all.
|
|
994
|
+
if (!this.isTopLevelScope()) {
|
|
995
|
+
this.diagnostics.push(diagnostic("VEL5037", "Unsafe CSS is module-level; move the declaration to the top of the module so its order against Look stays visible", statement.span));
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
if (statement.source.kind === "external" && this.unsafeCssImports.has(statement.source.path)) {
|
|
999
|
+
this.diagnostics.push(diagnostic("VEL5037", `Unsafe CSS '${statement.source.path}' is imported more than once; each stylesheet must have one explicit order position`, statement.span));
|
|
1000
|
+
}
|
|
1001
|
+
if (statement.source.kind === "external")
|
|
1002
|
+
this.unsafeCssImports.add(statement.source.path);
|
|
1003
|
+
const source = statement.source.kind === "inline" ? statement.source.css : this.resources.get(statement.source.path);
|
|
1004
|
+
const subject = statement.source.kind === "inline" ? "Inline unsafe CSS" : `Unsafe CSS '${statement.source.path}'`;
|
|
1005
|
+
if (source && containsCssImport(source)) {
|
|
1006
|
+
this.diagnostics.push(diagnostic("VEL5037", `${subject} contains @import; declare every stylesheet with 'import css unsafe' so project order remains visible`, statement.source.span));
|
|
1007
|
+
}
|
|
1008
|
+
if (source) {
|
|
1009
|
+
const relativeAddress = firstRelativeCssAssetAddress(source);
|
|
1010
|
+
if (relativeAddress)
|
|
1011
|
+
this.diagnostics.push(diagnostic("VEL5037", `${subject} uses relative asset address ${relativeAddress.syntax}(${JSON.stringify(relativeAddress.value)}); use a project-public /path, data URL, fragment, or absolute URL so extracted asset ownership stays explicit`, statement.source.span));
|
|
1012
|
+
}
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
default:
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
analyzeStatement(statement) {
|
|
1020
|
+
const readonlyProp = this.directReadonlyPropMutation(statement);
|
|
1021
|
+
const firstDiagnostic = this.diagnostics.length;
|
|
1022
|
+
super.analyzeStatement(statement);
|
|
1023
|
+
if (!readonlyProp)
|
|
1024
|
+
return;
|
|
1025
|
+
for (let index = firstDiagnostic; index < this.diagnostics.length; index += 1) {
|
|
1026
|
+
const item = this.diagnostics[index];
|
|
1027
|
+
if ((item.code !== "VEL3002" && item.code !== "VEL4001") || !/read-?only|readonly/iu.test(item.message))
|
|
1028
|
+
continue;
|
|
1029
|
+
this.diagnostics[index] = {
|
|
1030
|
+
...item,
|
|
1031
|
+
message: `Cannot mutate prop '${readonlyProp}': this component's author explicitly declared it 'readonly'. ${item.message}`,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
directReadonlyPropMutation(statement) {
|
|
1036
|
+
let target = null;
|
|
1037
|
+
if (statement.kind === "AssignmentStatement" && statement.target.kind !== "IdentifierExpression") {
|
|
1038
|
+
target = statement.target;
|
|
1039
|
+
}
|
|
1040
|
+
else if (statement.kind === "ExpressionStatement" && statement.expression.kind === "CallExpression"
|
|
1041
|
+
&& statement.expression.callee.kind === "MemberExpression") {
|
|
1042
|
+
target = statement.expression.callee.object;
|
|
1043
|
+
}
|
|
1044
|
+
if (!target)
|
|
1045
|
+
return null;
|
|
1046
|
+
const name = this.rootBindingName(target);
|
|
1047
|
+
if (!name)
|
|
1048
|
+
return null;
|
|
1049
|
+
const binding = this.lookup(name);
|
|
1050
|
+
return binding && this.explicitReadonlyPropBindings.get(name) === binding.span.start ? name : null;
|
|
1051
|
+
}
|
|
1052
|
+
rootBindingName(expression) {
|
|
1053
|
+
if (expression.kind === "IdentifierExpression")
|
|
1054
|
+
return expression.name;
|
|
1055
|
+
if (expression.kind === "MemberExpression" || expression.kind === "IndexExpression") {
|
|
1056
|
+
return this.rootBindingName(expression.object);
|
|
1057
|
+
}
|
|
1058
|
+
if (expression.kind === "CallExpression" && expression.callee.kind === "MemberExpression") {
|
|
1059
|
+
return this.rootBindingName(expression.callee.object);
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
prescanExtensionScopeDeclaration(statement) {
|
|
1064
|
+
if (!isWebStatement(statement))
|
|
1065
|
+
return null;
|
|
1066
|
+
return statement.kind === "ExtensionStatement:web:state" || statement.kind === "ExtensionStatement:web:computed"
|
|
1067
|
+
|| statement.kind === "ExtensionStatement:web:resource"
|
|
1068
|
+
? { name: statement.name, span: statement.span }
|
|
1069
|
+
: null;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* D71 rule 182: a derived value is reactive and read-only, which is exactly
|
|
1073
|
+
* the reactive identity a component prop already carries — a bare read lowers
|
|
1074
|
+
* through `.get()`, and nothing may write it. Registering that identity
|
|
1075
|
+
* rather than `state` is what keeps `bind={doubled}` and every other writable
|
|
1076
|
+
* position refusing a derived name for free.
|
|
1077
|
+
*/
|
|
1078
|
+
analyzeComputedDeclaration(statement) {
|
|
1079
|
+
const annotationValid = statement.type ? this.validateTypeReference(statement.type) : true;
|
|
1080
|
+
const annotationContext = statement.type ? this.resolveValidatedAnnotation(statement.type) : null;
|
|
1081
|
+
// The initializer re-runs on every dependency change, so it is a deferred
|
|
1082
|
+
// read for the module-initialization-cycle classification, exactly like a
|
|
1083
|
+
// watch subject.
|
|
1084
|
+
this.synchronousReactiveDepth += 1;
|
|
1085
|
+
const actual = this.inferExpression(statement.initializer, annotationContext ?? unknownType);
|
|
1086
|
+
this.synchronousReactiveDepth -= 1;
|
|
1087
|
+
const declared = annotationContext ?? actual;
|
|
1088
|
+
if (annotationValid)
|
|
1089
|
+
this.requireAssignable(actual, declared, statement.initializer.span);
|
|
1090
|
+
this.declareBinding(statement.name, false, declared, statement.span);
|
|
1091
|
+
this.markDeclaredBindingReactive(statement.name, "prop");
|
|
1092
|
+
this.computedBindingSpans.add(spanIdentity(statement.span));
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* True when `name` resolves to a `computed` declaration or to an imported one.
|
|
1096
|
+
* The question is asked of the *binding* the name resolves to, never of the
|
|
1097
|
+
* spelling: a local `state` may shadow an imported derived name, and that
|
|
1098
|
+
* shadow is writable state.
|
|
1099
|
+
*/
|
|
1100
|
+
isComputedBinding(name) {
|
|
1101
|
+
const binding = this.lookup(name);
|
|
1102
|
+
return binding !== null && this.computedBindingSpans.has(spanIdentity(binding.span));
|
|
1103
|
+
}
|
|
1104
|
+
isImportedComputedBinding(name) {
|
|
1105
|
+
const binding = this.lookup(name);
|
|
1106
|
+
return binding !== null && this.importedComputedSpans.has(spanIdentity(binding.span));
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* D71 rule 184: a cross-module `computed` travels through `reactiveExports`
|
|
1110
|
+
* so its bare read lowers through `.get()` like an exported `state` — but it
|
|
1111
|
+
* is not writable, and the imported binding must not inherit the writable
|
|
1112
|
+
* identity that carries `bind={...}` and `event => name = ...`. The Web
|
|
1113
|
+
* extension publishes which exported names are derived, so the import is
|
|
1114
|
+
* demoted to the read-only reactive identity here.
|
|
1115
|
+
*/
|
|
1116
|
+
markDeclaredBindingReactive(name, kind = "state") {
|
|
1117
|
+
// Only the import itself is demoted. A local `state` of the same name in a
|
|
1118
|
+
// component or a block is a shadow that really is writable, and demoting it
|
|
1119
|
+
// would compile its assignment as a plain store into the handle.
|
|
1120
|
+
const imported = kind === "state" && this.isTopLevelScope() && this.importedComputedNames.has(name);
|
|
1121
|
+
super.markDeclaredBindingReactive(name, imported ? "prop" : kind);
|
|
1122
|
+
if (!imported)
|
|
1123
|
+
return;
|
|
1124
|
+
const binding = this.lookup(name);
|
|
1125
|
+
if (!binding)
|
|
1126
|
+
return;
|
|
1127
|
+
this.computedBindingSpans.add(spanIdentity(binding.span));
|
|
1128
|
+
this.importedComputedSpans.add(spanIdentity(binding.span));
|
|
1129
|
+
}
|
|
1130
|
+
inferExtensionExpression(expression, _contextualType) {
|
|
1131
|
+
if (expression.kind === "CallExpression" && expression.callee.kind === "IdentifierExpression"
|
|
1132
|
+
&& expression.callee.name === "mount") {
|
|
1133
|
+
const namedNode = expression.argumentNames?.findIndex((name) => name === "node") ?? -1;
|
|
1134
|
+
const node = expression.arguments[namedNode >= 0 ? namedNode : 0];
|
|
1135
|
+
if (node && expressionContainsDirectAwait(node, (value) => value.kind === "ExtensionExpression:web:jsx" ? false : undefined)) {
|
|
1136
|
+
this.diagnostics.push(diagnostic("VEL4007", "mount constructs its root synchronously; await the root in a separate module binding before calling mount", node.span));
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (expression.kind === "UnaryExpression" && (expression.operator === "+" || expression.operator === "-")) {
|
|
1140
|
+
const operand = this.inferExpression(expression.operand);
|
|
1141
|
+
if (isLookNumericType(operand)) {
|
|
1142
|
+
this.extensionCalls.set(spanIdentity(expression.span), LOOK_ARITHMETIC_HINT);
|
|
1143
|
+
return operand;
|
|
1144
|
+
}
|
|
1145
|
+
this.probedOperandTypes.set(spanIdentity(expression.operand.span), operand);
|
|
1146
|
+
}
|
|
1147
|
+
// WEB-U15 / GRM-A3: '&&' rendering is the React habit. The lexer now starts
|
|
1148
|
+
// JSX after 'and'/'or' so the shape parses and this rejection names the
|
|
1149
|
+
// conditional-rendering spelling instead of leaking a bool type error.
|
|
1150
|
+
if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")
|
|
1151
|
+
&& (isWebJsx(expression.left) || isWebJsx(expression.right))) {
|
|
1152
|
+
const rightIsElement = isWebJsx(expression.right);
|
|
1153
|
+
const tag = rightIsElement ? expression.right.tag : isWebJsx(expression.left) ? expression.left.tag : "";
|
|
1154
|
+
const condition = lookSourceOf(rightIsElement ? expression.left : expression.right);
|
|
1155
|
+
this.inferExpression(expression.left);
|
|
1156
|
+
this.inferExpression(expression.right);
|
|
1157
|
+
this.diagnostics.push(diagnostic("VEL5029", `'${expression.operator}' combines bool values and cannot yield an element; render conditionally with '{${condition} ? <${tag || ">"} ... : null}'`, expression.span));
|
|
1158
|
+
return invalidType;
|
|
1159
|
+
}
|
|
1160
|
+
if (expression.kind === "BinaryExpression" && ["+", "-", "*", "/"].includes(expression.operator)) {
|
|
1161
|
+
const left = this.inferExpression(expression.left);
|
|
1162
|
+
const right = this.inferExpression(expression.right);
|
|
1163
|
+
if (!isLookNumericType(left) && !isLookNumericType(right)) {
|
|
1164
|
+
this.probedOperandTypes.set(spanIdentity(expression.left.span), left);
|
|
1165
|
+
this.probedOperandTypes.set(spanIdentity(expression.right.span), right);
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
const additive = expression.operator === "+" || expression.operator === "-" ? lookAdditiveType(left, right) : null;
|
|
1169
|
+
const result = additive
|
|
1170
|
+
?? ((expression.operator === "*" || expression.operator === "/") && isLookNumericType(left) && right.kind === "number" ? left : null)
|
|
1171
|
+
?? (expression.operator === "*" && left.kind === "number" && isLookNumericType(right) ? right : null);
|
|
1172
|
+
if (result) {
|
|
1173
|
+
// LOK-U8: dividing a visual value by a literal zero produces a
|
|
1174
|
+
// non-finite length that the runtime rejects on first construction.
|
|
1175
|
+
if (expression.operator === "/" && lookLiteralZero(expression.right)) {
|
|
1176
|
+
this.diagnostics.push(diagnostic("VEL5042", "Look unit arithmetic cannot divide by zero", expression.span));
|
|
1177
|
+
return invalidType;
|
|
1178
|
+
}
|
|
1179
|
+
this.extensionCalls.set(spanIdentity(expression.span), LOOK_ARITHMETIC_HINT);
|
|
1180
|
+
return result;
|
|
1181
|
+
}
|
|
1182
|
+
// The rejection is final: returning the invalid type keeps the Look
|
|
1183
|
+
// property's own assignment error from co-reporting a union dump on the
|
|
1184
|
+
// very expression that was already named (LOK-I1).
|
|
1185
|
+
this.diagnostics.push(diagnostic("VEL5042", `Look unit arithmetic cannot apply '${expression.operator}' to ${describeType(left)} and ${describeType(right)}`, expression.span));
|
|
1186
|
+
return invalidType;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// WEB-N2 / D47 rule 84: the event object is typed down to event semantics
|
|
1190
|
+
// and deliberately carries no target, so `event.target.value` is a dead end
|
|
1191
|
+
// that used to cascade into three unknown-access errors. The read is where
|
|
1192
|
+
// the author wanted two-way binding, so it names that spelling and stops.
|
|
1193
|
+
if (expression.kind === "MemberExpression" && !expression.optional && webEventDeadFields.has(expression.property)
|
|
1194
|
+
&& expression.object.kind === "IdentifierExpression") {
|
|
1195
|
+
const binding = this.lookup(expression.object.name);
|
|
1196
|
+
const expanded = binding ? this.expandAliases(binding.type) : null;
|
|
1197
|
+
if (expanded?.kind === "named" && webEventTypeNames.has(expanded.name)) {
|
|
1198
|
+
this.diagnostics.push(diagnostic("VEL5019", `A VelarScript event object carries typed event fields only and has no '${expression.property}': read the element's value through a two-way binding instead — 'bind:value={name}' binds a state name, and 'bind:value={form.field}' or 'bind:value={items[0]}' binds a writable path inside state`, expression.span));
|
|
1199
|
+
return invalidType;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (isWebJsx(expression))
|
|
1203
|
+
return this.inferJsx(expression);
|
|
1204
|
+
if (isWebKeyframes(expression)) {
|
|
1205
|
+
this.analyzeKeyframes(expression);
|
|
1206
|
+
return { kind: "named", name: "Keyframes" };
|
|
1207
|
+
}
|
|
1208
|
+
if (isWebLook(expression)) {
|
|
1209
|
+
this.lookLiteralDepth += 1;
|
|
1210
|
+
try {
|
|
1211
|
+
this.analyzeLookEntries(expression.entries, false, false, 1, `look:${expression.span.start}`);
|
|
1212
|
+
}
|
|
1213
|
+
finally {
|
|
1214
|
+
this.lookLiteralDepth -= 1;
|
|
1215
|
+
}
|
|
1216
|
+
return { kind: "named", name: "Look" };
|
|
1217
|
+
}
|
|
1218
|
+
if (isWebExpression(expression) && expression.kind === "ExtensionExpression:web:look-hook") {
|
|
1219
|
+
this.diagnostics.push(diagnostic("VEL5038", `Look hook '@${expression.name}' is only valid inside a Look condition`, expression.span));
|
|
1220
|
+
return boolType;
|
|
1221
|
+
}
|
|
1222
|
+
if (isWebUnit(expression)) {
|
|
1223
|
+
const type = LOOK_UNIT_TYPES.get(expression.unit);
|
|
1224
|
+
if (type)
|
|
1225
|
+
return { kind: "named", name: type };
|
|
1226
|
+
return unknownType;
|
|
1227
|
+
}
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
inferExpression(expression, contextualType = unknownType) {
|
|
1231
|
+
// Operands probed for Look arithmetic are re-requested by the core analyzer immediately after the
|
|
1232
|
+
// probe declines; reusing the probe result (consume-once) keeps operand analysis single-run so
|
|
1233
|
+
// operand diagnostics are not reported twice.
|
|
1234
|
+
const key = spanIdentity(expression.span);
|
|
1235
|
+
const probed = this.probedOperandTypes.get(key);
|
|
1236
|
+
if (probed !== undefined) {
|
|
1237
|
+
this.probedOperandTypes.delete(key);
|
|
1238
|
+
return probed;
|
|
1239
|
+
}
|
|
1240
|
+
if (expression.kind === "CallExpression" && expression.arguments.length === 0
|
|
1241
|
+
&& expression.callee.kind === "IdentifierExpression") {
|
|
1242
|
+
this.plainCallSpans.set(spanIdentity(expression.callee.span), expression.span);
|
|
1243
|
+
const called = this.calledComputedBinding(expression);
|
|
1244
|
+
if (called)
|
|
1245
|
+
return called;
|
|
1246
|
+
}
|
|
1247
|
+
if (expression.kind === "IdentifierExpression") {
|
|
1248
|
+
const retired = this.recordRetiredAccessorRead(expression);
|
|
1249
|
+
// The retired name is answered here rather than left to fall through as an
|
|
1250
|
+
// unknown one: the author gets its migration and nothing else, and the
|
|
1251
|
+
// call around it still type-checks against the signature it always had.
|
|
1252
|
+
if (retired)
|
|
1253
|
+
return CACHED_INTRINSIC_TYPE;
|
|
1254
|
+
}
|
|
1255
|
+
const result = super.inferExpression(expression, contextualType);
|
|
1256
|
+
if (expression.kind === "CallExpression")
|
|
1257
|
+
this.checkLookBuilderCall(expression);
|
|
1258
|
+
return result;
|
|
1259
|
+
}
|
|
1260
|
+
// A name refers to writable reactive state only when ordinary lexical lookup
|
|
1261
|
+
// still resolves it to the state binding; a shadowing local wins instead.
|
|
1262
|
+
writableStateName(name) {
|
|
1263
|
+
return this.reactiveBindingKind(name) === "state";
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* D71 rule 182: a declared derived value is read bare, exactly like state.
|
|
1267
|
+
* Calling one is the habit the retired `computed(...)` accessor taught, and it
|
|
1268
|
+
* is also what a half-migrated project looks like from the importing side — so
|
|
1269
|
+
* the answer names the one spelling and carries the edit that reaches it,
|
|
1270
|
+
* which is what lets `velar fix` finish a migration that crosses modules.
|
|
1271
|
+
*/
|
|
1272
|
+
calledComputedBinding(expression) {
|
|
1273
|
+
if (expression.callee.kind !== "IdentifierExpression")
|
|
1274
|
+
return null;
|
|
1275
|
+
const name = expression.callee.name;
|
|
1276
|
+
if (!this.isComputedBinding(name))
|
|
1277
|
+
return null;
|
|
1278
|
+
const type = this.inferExpression(expression.callee);
|
|
1279
|
+
// A derived value that *is* a function is called on purpose; only a
|
|
1280
|
+
// non-callable one makes the parentheses a mistake.
|
|
1281
|
+
const expanded = this.expandAliases(type);
|
|
1282
|
+
if (expanded.kind === "function" || expanded.kind === "intrinsic" || expanded.kind === "any" || isInvalidType(expanded))
|
|
1283
|
+
return null;
|
|
1284
|
+
this.diagnostics.push({
|
|
1285
|
+
code: "VEL5063",
|
|
1286
|
+
message: `'${name}' is a computed value, not a reader: it is read bare like state, so write '${name}' rather than '${name}()'`,
|
|
1287
|
+
span: expression.span,
|
|
1288
|
+
fix: {
|
|
1289
|
+
title: `Read '${name}' bare`,
|
|
1290
|
+
edits: [{ span: { start: expression.callee.span.end, end: expression.span.end }, text: "" }],
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
return type;
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* D69 rule 178: a `watch` body that can never run is a block of statements
|
|
1297
|
+
* the compile silently drops — the same defect a bare `5` is already rejected
|
|
1298
|
+
* for (VEL4030), reached from a position the rule could not see. The subject
|
|
1299
|
+
* is refused only where the compile can *prove* nothing behind it moves, so a
|
|
1300
|
+
* call whose reactivity lives inside another module is left alone: a rule that
|
|
1301
|
+
* rejected those would be worse than the hole it closes.
|
|
1302
|
+
*
|
|
1303
|
+
* The two refusals are separate because their causes are: a reader that was
|
|
1304
|
+
* not called has one correct spelling to name, and a frozen value has none.
|
|
1305
|
+
*/
|
|
1306
|
+
rejectFrozenWatchSubject(expression, watched) {
|
|
1307
|
+
const name = expression.kind === "IdentifierExpression" ? expression.name : null;
|
|
1308
|
+
if (name !== null && this.reactiveBindingKind(name) === null && this.zeroArgumentReader(watched)) {
|
|
1309
|
+
this.diagnostics.push(diagnostic("VEL5064", `'${name}' is the reader itself, so watching it watches a value that never changes; write 'watch ${name}():' to watch what it reads`, expression.span));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (!this.frozenWatchSubject(expression))
|
|
1313
|
+
return;
|
|
1314
|
+
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));
|
|
1315
|
+
}
|
|
1316
|
+
/** A value that is read by calling it and takes no arguments to do so. */
|
|
1317
|
+
zeroArgumentReader(type) {
|
|
1318
|
+
const expanded = this.expandAliases(type);
|
|
1319
|
+
return (expanded.kind === "function" || expanded.kind === "intrinsic") && expanded.requiredParameters === 0;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* True only for subjects built entirely from values the compile can see are
|
|
1323
|
+
* frozen: literals, and non-reactive bindings whose type is a primitive, so no
|
|
1324
|
+
* deep-reactive object can be hiding behind the name. Every member access,
|
|
1325
|
+
* index, and call is excluded on purpose — `alias.done` on a const bound to a
|
|
1326
|
+
* reactive element does track, and a call can read anything.
|
|
1327
|
+
*/
|
|
1328
|
+
frozenWatchSubject(expression) {
|
|
1329
|
+
switch (expression.kind) {
|
|
1330
|
+
case "LiteralExpression":
|
|
1331
|
+
return true;
|
|
1332
|
+
case "FStringExpression":
|
|
1333
|
+
return expression.parts.every((part) => part.kind === "text" || this.frozenWatchSubject(part.value));
|
|
1334
|
+
case "IdentifierExpression":
|
|
1335
|
+
return this.reactiveBindingKind(expression.name) === null
|
|
1336
|
+
&& !this.derivedReactiveNames.has(expression.name)
|
|
1337
|
+
&& this.frozenValueType(this.lookup(expression.name)?.type ?? unknownType);
|
|
1338
|
+
case "UnaryExpression":
|
|
1339
|
+
return expression.operator !== "await" && this.frozenWatchSubject(expression.operand);
|
|
1340
|
+
case "BinaryExpression":
|
|
1341
|
+
return this.frozenWatchSubject(expression.left) && this.frozenWatchSubject(expression.right);
|
|
1342
|
+
case "ComparisonChainExpression":
|
|
1343
|
+
return expression.operands.every((operand) => this.frozenWatchSubject(operand));
|
|
1344
|
+
case "ConditionalExpression":
|
|
1345
|
+
return this.frozenWatchSubject(expression.condition) && this.frozenWatchSubject(expression.thenValue)
|
|
1346
|
+
&& this.frozenWatchSubject(expression.elseValue);
|
|
1347
|
+
case "IsExpression":
|
|
1348
|
+
return this.frozenWatchSubject(expression.value);
|
|
1349
|
+
default:
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/** A primitive holds no reactive identity, so a non-reactive binding of one is a snapshot. */
|
|
1354
|
+
frozenValueType(type) {
|
|
1355
|
+
const expanded = this.expandAliases(type);
|
|
1356
|
+
if (expanded.kind === "optional")
|
|
1357
|
+
return this.frozenValueType(expanded.inner);
|
|
1358
|
+
if (expanded.kind === "union")
|
|
1359
|
+
return expanded.members.every((member) => this.frozenValueType(member));
|
|
1360
|
+
return expanded.kind === "number" || expanded.kind === "string" || expanded.kind === "bool"
|
|
1361
|
+
|| expanded.kind === "null" || expanded.kind === "enum";
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* D71 rule 182: a derived value has no writable location behind it, so the
|
|
1365
|
+
* const message ("cannot assign to const binding") would name the wrong
|
|
1366
|
+
* reason. Answering here means the reader is told what a derived value is and
|
|
1367
|
+
* which spelling holds a value that is written.
|
|
1368
|
+
*/
|
|
1369
|
+
rejectComputedAssignment(statement) {
|
|
1370
|
+
if (statement.target.kind !== "IdentifierExpression")
|
|
1371
|
+
return false;
|
|
1372
|
+
const name = statement.target.name;
|
|
1373
|
+
if (!this.isComputedBinding(name))
|
|
1374
|
+
return false;
|
|
1375
|
+
this.diagnostics.push(diagnostic("VEL5063", this.isImportedComputedBinding(name)
|
|
1376
|
+
? `'${name}' is a computed value derived in the module it comes from, so it has no location here to assign. Export a 'state' — or an action that writes one — and change that instead`
|
|
1377
|
+
: `'${name}' is a computed value: it is recomputed from what it reads and is never assigned. Assign the state it reads, or declare it 'state ${name} = ...' if this value is written directly`, statement.target.span));
|
|
1378
|
+
this.inferExpression(statement.value);
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* D71 rule 183: `computed(...)` the function is retired in favour of
|
|
1383
|
+
* `cached(...)`, and `const x = computed(() => E)` is retired in favour of the
|
|
1384
|
+
* declaration. The declaration is recorded before the core walks the module so
|
|
1385
|
+
* its reads can be matched against it — the rewrite that removes the call
|
|
1386
|
+
* parentheses is only offered when every read is a plain `x()`.
|
|
1387
|
+
*/
|
|
1388
|
+
recordRetiredAccessorDeclaration(statement) {
|
|
1389
|
+
const initializer = statement.initializer;
|
|
1390
|
+
if (initializer.kind !== "CallExpression" || initializer.callee.kind !== "IdentifierExpression"
|
|
1391
|
+
|| initializer.callee.name !== "computed" || this.lookup("computed") !== null)
|
|
1392
|
+
return;
|
|
1393
|
+
if (statement.binding !== "const" || statement.pattern.kind !== "NameBindingPattern")
|
|
1394
|
+
return;
|
|
1395
|
+
const read = initializer.arguments.length === 1 && initializer.argumentNames === undefined
|
|
1396
|
+
? initializer.arguments[0] : null;
|
|
1397
|
+
// Only the `() => E` shape has a body that becomes the declaration's
|
|
1398
|
+
// initializer verbatim. Every other argument — a named function, a partial
|
|
1399
|
+
// application — would need the rewriter to invent an expression, so it is
|
|
1400
|
+
// left to the author with the rename as its only mechanical answer.
|
|
1401
|
+
const body = read?.kind === "ArrowFunctionExpression" && !read.asynchronous && read.parameters.length === 0
|
|
1402
|
+
? read.body : null;
|
|
1403
|
+
this.retiredAccessorDeclarations.set(spanIdentity(statement.pattern.span), {
|
|
1404
|
+
name: statement.pattern.name,
|
|
1405
|
+
exported: statement.exported,
|
|
1406
|
+
declarationSpan: statement.span,
|
|
1407
|
+
callSpan: initializer.span,
|
|
1408
|
+
bodySpan: body ? body.span : null,
|
|
1409
|
+
});
|
|
1410
|
+
this.migratedComputedCallees.add(spanIdentity(initializer.callee.span));
|
|
1411
|
+
}
|
|
1412
|
+
/** Returns true when the name is the retired `computed` global this pass owns. */
|
|
1413
|
+
recordRetiredAccessorRead(expression) {
|
|
1414
|
+
if (expression.name === "computed") {
|
|
1415
|
+
if (this.lookup("computed") !== null)
|
|
1416
|
+
return false;
|
|
1417
|
+
if (!this.migratedComputedCallees.has(spanIdentity(expression.span))) {
|
|
1418
|
+
this.retiredComputedReferences.set(spanIdentity(expression.span), expression.span);
|
|
1419
|
+
}
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
const binding = this.lookup(expression.name);
|
|
1423
|
+
if (!binding)
|
|
1424
|
+
return false;
|
|
1425
|
+
const key = spanIdentity(binding.span);
|
|
1426
|
+
if (!this.retiredAccessorDeclarations.has(key))
|
|
1427
|
+
return false;
|
|
1428
|
+
const reads = this.retiredAccessorReads.get(key);
|
|
1429
|
+
if (reads)
|
|
1430
|
+
reads.push(expression.span);
|
|
1431
|
+
else
|
|
1432
|
+
this.retiredAccessorReads.set(key, [expression.span]);
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* D71 migration: one message per retired site, and a mechanical rewrite only
|
|
1437
|
+
* where the compile can prove the rewrite. Where the accessor is used as a
|
|
1438
|
+
* value the choice between `computed x = E` and `cached(...)` turns on whether
|
|
1439
|
+
* the caller wants the cache, which is the author's decision — so that site
|
|
1440
|
+
* gets the message and no edit.
|
|
1441
|
+
*/
|
|
1442
|
+
reportRetiredComputedFunction() {
|
|
1443
|
+
for (const [key, accessor] of this.retiredAccessorDeclarations) {
|
|
1444
|
+
const reads = this.retiredAccessorReads.get(key) ?? [];
|
|
1445
|
+
const calls = reads.map((span) => this.plainCallSpans.get(spanIdentity(span)) ?? null);
|
|
1446
|
+
const rewritable = accessor.bodySpan !== null && calls.every((call) => call !== null);
|
|
1447
|
+
const edits = rewritable
|
|
1448
|
+
? [
|
|
1449
|
+
{ span: { start: accessor.declarationSpan.start, end: accessor.bodySpan.start }, text: `${accessor.exported ? "export " : ""}computed ${accessor.name} = ` },
|
|
1450
|
+
{ span: { start: accessor.bodySpan.end, end: accessor.callSpan.end }, text: "" },
|
|
1451
|
+
...reads.map((span, index) => ({ span: { start: span.end, end: calls[index].end }, text: "" })),
|
|
1452
|
+
]
|
|
1453
|
+
: null;
|
|
1454
|
+
const alternative = accessor.bodySpan === null
|
|
1455
|
+
? ` Where the argument is a function rather than an expression, write the call — 'computed ${accessor.name} = read()' — or keep a passable cached reader with 'cached(...)'`
|
|
1456
|
+
: ` Where the reader itself is passed on rather than read here, keep it a value with 'cached(...)'`;
|
|
1457
|
+
this.diagnostics.push({
|
|
1458
|
+
code: "VEL5055",
|
|
1459
|
+
message: `A derived value is declared, not called: write 'computed ${accessor.name} = ...' and read '${accessor.name}' bare.${rewritable ? "" : alternative}`,
|
|
1460
|
+
span: accessor.declarationSpan,
|
|
1461
|
+
...(edits ? { fix: { title: `Declare '${accessor.name}' with computed`, edits } } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
for (const span of this.retiredComputedReferences.values()) {
|
|
1465
|
+
this.diagnostics.push({
|
|
1466
|
+
code: "VEL5055",
|
|
1467
|
+
message: "'computed' is the keyword that declares a derived value — 'computed name = expression'. The function that returns a cached reader is now 'cached'",
|
|
1468
|
+
span,
|
|
1469
|
+
fix: { title: "Rename computed to cached", edits: [{ span, text: "cached" }] },
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* D71 rule 183 keeps the export-boundary contract the retired spelling had:
|
|
1475
|
+
* an exported cached reader has no inferable public type, so the annotation
|
|
1476
|
+
* is required at the boundary rather than discovered by the importer.
|
|
1477
|
+
*/
|
|
1478
|
+
reportExportedCachedContract(statement) {
|
|
1479
|
+
if (!statement.exported || statement.type || statement.pattern.kind !== "NameBindingPattern")
|
|
1480
|
+
return;
|
|
1481
|
+
const initializer = statement.initializer;
|
|
1482
|
+
if (initializer.kind !== "CallExpression" || initializer.callee.kind !== "IdentifierExpression"
|
|
1483
|
+
|| initializer.callee.name !== "cached" || this.lookup("cached") !== null)
|
|
1484
|
+
return;
|
|
1485
|
+
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));
|
|
1486
|
+
}
|
|
1487
|
+
extensionFieldsOf(name) {
|
|
1488
|
+
return webTypeFields(name);
|
|
1489
|
+
}
|
|
1490
|
+
inferExtensionCall(callee, arguments_, argumentNames, callSpan) {
|
|
1491
|
+
if (!isWebComponentType(callee))
|
|
1492
|
+
return undefined;
|
|
1493
|
+
const name = webComponentName(callee);
|
|
1494
|
+
this.typeError(name ? `Render component '${name}' with JSX` : "Render a Component value with JSX", callSpan);
|
|
1495
|
+
if (argumentNames?.some((argument) => argument !== null)) {
|
|
1496
|
+
this.typeError("Components use JSX props rather than named call arguments", callSpan);
|
|
1497
|
+
}
|
|
1498
|
+
for (const argument of arguments_)
|
|
1499
|
+
this.inferExpression(argument);
|
|
1500
|
+
return webNodeType;
|
|
1501
|
+
}
|
|
1502
|
+
validateExtensionTypeSyntax(syntax, validate, resolve) {
|
|
1503
|
+
if (syntax.kind !== "GenericTypeSyntax" || syntax.name !== "Component")
|
|
1504
|
+
return undefined;
|
|
1505
|
+
let valid = true;
|
|
1506
|
+
if (syntax.arguments.length < 1 || syntax.arguments.length > 2) {
|
|
1507
|
+
this.typeError("Component<Props, Handle> requires a named prop signature and at most one Handle type", syntax.span);
|
|
1508
|
+
valid = false;
|
|
1509
|
+
}
|
|
1510
|
+
const argumentsValid = syntax.arguments.map(validate).every(Boolean);
|
|
1511
|
+
const signature = syntax.arguments[0];
|
|
1512
|
+
if (!signature)
|
|
1513
|
+
return false;
|
|
1514
|
+
if (signature.kind !== "FunctionTypeSyntax") {
|
|
1515
|
+
this.typeError("Component<Props, Handle> requires a named function signature such as Component<(title: string) -> WebNode, DialogHandle>", signature.span);
|
|
1516
|
+
valid = false;
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
const names = new Set();
|
|
1520
|
+
for (const parameter of signature.parameters) {
|
|
1521
|
+
if (!parameter.name) {
|
|
1522
|
+
this.typeError("Every Component signature prop requires a name", parameter.span);
|
|
1523
|
+
valid = false;
|
|
1524
|
+
}
|
|
1525
|
+
else if (names.has(parameter.name)) {
|
|
1526
|
+
this.typeError(`Component signature prop '${parameter.name}' is declared more than once`, parameter.span);
|
|
1527
|
+
valid = false;
|
|
1528
|
+
}
|
|
1529
|
+
else
|
|
1530
|
+
names.add(parameter.name);
|
|
1531
|
+
if (parameter.rest) {
|
|
1532
|
+
this.typeError("Component signatures use named props and cannot declare a rest parameter", parameter.span);
|
|
1533
|
+
valid = false;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const result = resolve({ syntax: signature.result, span: signature.result.span });
|
|
1537
|
+
if (!isWebNodeType(result)) {
|
|
1538
|
+
this.typeError(`A Component signature must return WebNode, received ${describeType(result)}`, signature.result.span);
|
|
1539
|
+
valid = false;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const handleSyntax = syntax.arguments[1];
|
|
1543
|
+
if (handleSyntax && argumentsValid) {
|
|
1544
|
+
const handle = this.expandAliases(resolve({ syntax: handleSyntax, span: handleSyntax.span }));
|
|
1545
|
+
const fields = handle.kind === "object"
|
|
1546
|
+
? handle.fields
|
|
1547
|
+
: handle.kind === "named" ? this.fieldsOf(handle.identity ?? handle.name) : null;
|
|
1548
|
+
if (!fields) {
|
|
1549
|
+
this.typeError(`A Component Handle must be a concrete record type, received ${describeType(handle)}`, handleSyntax.span);
|
|
1550
|
+
valid = false;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return valid && argumentsValid;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* D43 item 69: a component body is a construction section, not a scope with
|
|
1557
|
+
* an exit — its resources live until unmount. Ownership belongs to the
|
|
1558
|
+
* lifecycle hook or to a function inside the component, so the setup section
|
|
1559
|
+
* says so instead of releasing at the wrong moment.
|
|
1560
|
+
*/
|
|
1561
|
+
ownershipScopeRejection() {
|
|
1562
|
+
if (this.componentStates !== null && this.mountedDepth === 0 && this.cleanupDepth === 0 && this.watchBodyDepth === 0
|
|
1563
|
+
&& this.inComponentSetupPosition()) {
|
|
1564
|
+
return "A component body builds the component and does not end, so a 'using' here has no scope to release at; own the resource inside an action, a method, or the cleanup hook";
|
|
1565
|
+
}
|
|
1566
|
+
return super.ownershipScopeRejection();
|
|
1567
|
+
}
|
|
1568
|
+
invalidExtensionAwaitContext() {
|
|
1569
|
+
return this.synchronousReactiveDepth > 0 || this.jsxDepth > 0
|
|
1570
|
+
|| (this.componentStates !== null && this.mountedDepth === 0);
|
|
1571
|
+
}
|
|
1572
|
+
invalidExtensionAwaitMessage() {
|
|
1573
|
+
if (this.jsxDepth > 0)
|
|
1574
|
+
return "JSX rendering is synchronous; load async component data with a resource or await before constructing JSX";
|
|
1575
|
+
if (this.synchronousReactiveDepth > 0)
|
|
1576
|
+
return "Computed callbacks and watch blocks are synchronous; use resource, action, or mounted for async work";
|
|
1577
|
+
return "Component setup and cleanup are synchronous; use resource, action, or mounted for async work";
|
|
1578
|
+
}
|
|
1579
|
+
componentType(statement) {
|
|
1580
|
+
const props = new Map(statement.parameters.map((parameter) => [parameter.name, this.resolveValidatedAnnotation(parameter.type)]));
|
|
1581
|
+
if (!props.has("class"))
|
|
1582
|
+
props.set("class", optionalOf(stringType));
|
|
1583
|
+
if (!props.has("look"))
|
|
1584
|
+
props.set("look", optionalOf({ kind: "named", name: "Look" }));
|
|
1585
|
+
return webComponentConstructor(statement.name, props, new Set(statement.parameters.filter((parameter) => !parameter.defaultValue).map((parameter) => parameter.name)), statement.handleType ? this.resolveValidatedAnnotation(statement.handleType) : null);
|
|
1586
|
+
}
|
|
1587
|
+
analyzeResourceDeclaration(statement) {
|
|
1588
|
+
const annotated = statement.type ? this.resolveAnnotation(statement.type) : null;
|
|
1589
|
+
const annotationValid = statement.type ? this.validateTypeReference(statement.type) : true;
|
|
1590
|
+
const annotationContext = statement.type ? this.resolveValidatedAnnotation(statement.type) : null;
|
|
1591
|
+
const expected = annotationContext ? { kind: "promise", value: annotationContext } : unknownType;
|
|
1592
|
+
const actual = this.inferExpression(statement.initializer, expected);
|
|
1593
|
+
let value = annotationContext ?? unknownType;
|
|
1594
|
+
if (actual.kind === "promise") {
|
|
1595
|
+
if (annotated && annotationValid)
|
|
1596
|
+
this.requireAssignable(actual.value, annotated, statement.initializer.span);
|
|
1597
|
+
else if (!statement.type)
|
|
1598
|
+
value = actual.value;
|
|
1599
|
+
}
|
|
1600
|
+
else if (actual.kind === "any") {
|
|
1601
|
+
value = annotationContext ?? anyType;
|
|
1602
|
+
}
|
|
1603
|
+
else if (!isInvalidType(actual)) {
|
|
1604
|
+
this.diagnostics.push(diagnostic("VEL4016", `A resource initializer must return Promise<T>, received ${describeType(actual)}`, statement.initializer.span));
|
|
1605
|
+
}
|
|
1606
|
+
const fields = new Map([
|
|
1607
|
+
["value", value.kind === "null" ? nullType : optionalOf(value)],
|
|
1608
|
+
["loading", boolType],
|
|
1609
|
+
["ready", boolType],
|
|
1610
|
+
["error", optionalOf({ kind: "class", name: "Error" })],
|
|
1611
|
+
["reload", { kind: "function", parameters: [], requiredParameters: 0, result: { kind: "promise", value: nullType } }],
|
|
1612
|
+
]);
|
|
1613
|
+
this.declareBinding(statement.name, false, { kind: "object", fields }, statement.span);
|
|
1614
|
+
}
|
|
1615
|
+
actionType(statement) {
|
|
1616
|
+
const declaredResult = this.resolvedAsyncResult(this.inferredFunctionResult(statement));
|
|
1617
|
+
const rest = statement.parameters.find((parameter) => parameter.rest);
|
|
1618
|
+
return {
|
|
1619
|
+
kind: "action",
|
|
1620
|
+
parameters: statement.parameters.filter((parameter) => !parameter.rest).map((parameter) => this.resolveValidatedAnnotation(parameter.type)),
|
|
1621
|
+
parameterNames: statement.parameters.filter((parameter) => !parameter.rest).map((parameter) => parameter.name),
|
|
1622
|
+
requiredParameters: statement.parameters.filter((parameter) => !parameter.rest && !parameter.defaultValue).length,
|
|
1623
|
+
...(rest ? { rest: this.resolveValidatedAnnotation(rest.type) } : {}),
|
|
1624
|
+
result: { kind: "promise", value: declaredResult },
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
analyzeActionDeclaration(statement) {
|
|
1628
|
+
this.declareBinding(statement.name, false, this.actionType(statement), statement.span);
|
|
1629
|
+
this.analyzeFunctionDeclaration(statement, null, true, false, true, "Action");
|
|
1630
|
+
}
|
|
1631
|
+
analyzeComponent(statement) {
|
|
1632
|
+
const outerConstructorDepth = this.constructorDepth;
|
|
1633
|
+
if (!this.isPredeclared(statement))
|
|
1634
|
+
this.declareBinding(statement.name, false, this.componentType(statement), statement.span);
|
|
1635
|
+
this.enterScope();
|
|
1636
|
+
this.flowFrameDepth += 1;
|
|
1637
|
+
const previousStates = this.componentStates;
|
|
1638
|
+
this.componentStates = new Set(statement.body.filter((item) => item.kind === "ExtensionStatement:web:state").map((item) => item.name));
|
|
1639
|
+
const previousExplicitReadonlyProps = this.explicitReadonlyPropBindings;
|
|
1640
|
+
const explicitReadonlyProps = new Map();
|
|
1641
|
+
// Component items are analyzed one by one rather than through
|
|
1642
|
+
// analyzeStatements, so the shadow prescan runs here — before the
|
|
1643
|
+
// parameters, whose defaults are emitted as closures inside the component
|
|
1644
|
+
// body where a later item's shadow would capture them.
|
|
1645
|
+
this.prescanScopeDeclarations(statement.body.filter((item) => item.kind !== "ExtensionStatement:web:mounted" && item.kind !== "ExtensionStatement:web:cleanup" && item.kind !== "ExtensionStatement:web:expose"));
|
|
1646
|
+
for (const parameter of statement.parameters) {
|
|
1647
|
+
const type = this.resolveAnnotation(parameter.type);
|
|
1648
|
+
const valid = parameter.type ? this.validateTypeReference(parameter.type) : true;
|
|
1649
|
+
if (parameter.defaultValue && valid)
|
|
1650
|
+
this.requireAssignable(this.inferParameterDefault(parameter.defaultValue, type), type, parameter.defaultValue.span);
|
|
1651
|
+
const declared = valid ? type : this.resolveValidatedAnnotation(parameter.type);
|
|
1652
|
+
this.declareBinding(parameter.name, false, declared, parameter.span);
|
|
1653
|
+
if (valid && this.containsReadonlyView(declared))
|
|
1654
|
+
explicitReadonlyProps.set(parameter.name, parameter.span.start);
|
|
1655
|
+
this.markDeclaredBindingReactive(parameter.name, "prop");
|
|
1656
|
+
if (parameter.name === "ref")
|
|
1657
|
+
this.diagnostics.push(diagnostic("VEL5056", "'ref' is a compiler-owned JSX directive and cannot be declared as a component prop", parameter.span));
|
|
1658
|
+
}
|
|
1659
|
+
this.explicitReadonlyPropBindings = explicitReadonlyProps;
|
|
1660
|
+
const handleType = statement.handleType ? this.resolveAnnotation(statement.handleType) : null;
|
|
1661
|
+
const handleTypeValid = statement.handleType ? this.validateTypeReference(statement.handleType) : true;
|
|
1662
|
+
if (handleType && handleTypeValid)
|
|
1663
|
+
this.validateComponentHandleType(handleType, statement.handleType.span);
|
|
1664
|
+
this.constructorDepth = 0;
|
|
1665
|
+
let renders = 0;
|
|
1666
|
+
let renderValue = null;
|
|
1667
|
+
let mounted = 0;
|
|
1668
|
+
let cleanup = 0;
|
|
1669
|
+
let exposes = 0;
|
|
1670
|
+
for (const item of statement.body) {
|
|
1671
|
+
if (item.kind === "ExtensionStatement:web:state") {
|
|
1672
|
+
const annotationValid = item.type ? this.validateTypeReference(item.type) : true;
|
|
1673
|
+
const annotationContext = item.type ? this.resolveValidatedAnnotation(item.type) : null;
|
|
1674
|
+
const actual = this.inferExpression(item.initializer, annotationContext ?? unknownType);
|
|
1675
|
+
const declared = annotationContext ?? actual;
|
|
1676
|
+
if (annotationValid)
|
|
1677
|
+
this.requireAssignable(actual, declared, item.initializer.span);
|
|
1678
|
+
this.declareBinding(item.name, true, declared, item.span);
|
|
1679
|
+
this.markDeclaredBindingReactive(item.name, "state");
|
|
1680
|
+
}
|
|
1681
|
+
else if (item.kind === "ExtensionStatement:web:computed") {
|
|
1682
|
+
this.flowFrameDepth += 1;
|
|
1683
|
+
this.analyzeComputedDeclaration(item);
|
|
1684
|
+
this.flowFrameDepth -= 1;
|
|
1685
|
+
}
|
|
1686
|
+
else if (item.kind === "ExtensionStatement:web:resource") {
|
|
1687
|
+
this.flowFrameDepth += 1;
|
|
1688
|
+
this.analyzeResourceDeclaration(item);
|
|
1689
|
+
this.flowFrameDepth -= 1;
|
|
1690
|
+
}
|
|
1691
|
+
else if (item.kind === "ExtensionStatement:web:action") {
|
|
1692
|
+
this.analyzeActionDeclaration(item);
|
|
1693
|
+
}
|
|
1694
|
+
else if (item.kind === "ExtensionStatement:web:watch") {
|
|
1695
|
+
this.flowFrameDepth += 1;
|
|
1696
|
+
this.synchronousReactiveDepth += 1;
|
|
1697
|
+
const watched = this.inferExpression(item.expression);
|
|
1698
|
+
this.rejectFrozenWatchSubject(item.expression, watched);
|
|
1699
|
+
this.enterScope();
|
|
1700
|
+
if (item.currentName)
|
|
1701
|
+
this.declareBinding(item.currentName, false, watched, item.span);
|
|
1702
|
+
if (item.previousName)
|
|
1703
|
+
this.declareBinding(item.previousName, false, watched, item.span);
|
|
1704
|
+
this.watchBodyDepth += 1;
|
|
1705
|
+
this.analyzeStatements(item.body);
|
|
1706
|
+
this.watchBodyDepth -= 1;
|
|
1707
|
+
this.exitScope();
|
|
1708
|
+
this.synchronousReactiveDepth -= 1;
|
|
1709
|
+
this.flowFrameDepth -= 1;
|
|
1710
|
+
}
|
|
1711
|
+
else if (item.kind === "ExtensionStatement:web:expose") {
|
|
1712
|
+
exposes += 1;
|
|
1713
|
+
this.flowFrameDepth += 1;
|
|
1714
|
+
const actual = this.inferExpression(item.value, handleType ?? unknownType);
|
|
1715
|
+
this.flowFrameDepth -= 1;
|
|
1716
|
+
if (!handleType)
|
|
1717
|
+
this.diagnostics.push(diagnostic("VEL5056", `Component '${statement.name}' uses 'expose' without declaring 'exposes HandleType'`, item.span));
|
|
1718
|
+
else if (handleTypeValid)
|
|
1719
|
+
this.requireAssignable(actual, handleType, item.value.span);
|
|
1720
|
+
}
|
|
1721
|
+
else if (item.kind === "ExtensionStatement:web:mounted") {
|
|
1722
|
+
mounted += 1;
|
|
1723
|
+
this.mountedDepth += 1;
|
|
1724
|
+
this.flowFrameDepth += 1;
|
|
1725
|
+
this.analyzeBlock(item.body);
|
|
1726
|
+
this.flowFrameDepth -= 1;
|
|
1727
|
+
this.mountedDepth -= 1;
|
|
1728
|
+
}
|
|
1729
|
+
else if (item.kind === "ExtensionStatement:web:cleanup") {
|
|
1730
|
+
cleanup += 1;
|
|
1731
|
+
// D51 (audit 12): a cleanup hook runs once and ends, so it is an
|
|
1732
|
+
// ordinary scope with an exit. It used to be counted as component
|
|
1733
|
+
// setup, and the rejection then named `@cleanup` as its own fix.
|
|
1734
|
+
this.cleanupDepth += 1;
|
|
1735
|
+
this.flowFrameDepth += 1;
|
|
1736
|
+
this.analyzeBlock(item.body);
|
|
1737
|
+
this.flowFrameDepth -= 1;
|
|
1738
|
+
this.cleanupDepth -= 1;
|
|
1739
|
+
}
|
|
1740
|
+
else if (item.kind === "ReturnStatement") {
|
|
1741
|
+
renders += 1;
|
|
1742
|
+
renderValue = item.value;
|
|
1743
|
+
this.flowFrameDepth += 1;
|
|
1744
|
+
const rendered = item.value ? this.inferExpression(item.value) : nullType;
|
|
1745
|
+
this.flowFrameDepth -= 1;
|
|
1746
|
+
// WEB-U15: `return null` is the React habit for "render nothing". A
|
|
1747
|
+
// component always has one root, so the decision belongs to the caller.
|
|
1748
|
+
if (rendered.kind === "null") {
|
|
1749
|
+
this.typeError("A component always returns one JSX root; decide at the call site with '{show ? <Card /> : null}', or return an empty element such as <span />", item.span);
|
|
1750
|
+
}
|
|
1751
|
+
else if (!isWebNodeType(rendered) && rendered.kind !== "any")
|
|
1752
|
+
this.typeError("A component must return JSX", item.span);
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
this.analyzeStatement(item);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
if (renders !== 1)
|
|
1759
|
+
this.diagnostics.push(diagnostic("VEL5008", `Component '${statement.name}' must have exactly one top-level return`, statement.span));
|
|
1760
|
+
if (mounted > 1)
|
|
1761
|
+
this.diagnostics.push(diagnostic("VEL5009", `Component '${statement.name}' has more than one '@mounted' block`, statement.span));
|
|
1762
|
+
if (cleanup > 1)
|
|
1763
|
+
this.diagnostics.push(diagnostic("VEL5010", `Component '${statement.name}' has more than one '@cleanup' block`, statement.span));
|
|
1764
|
+
if (exposes > 1)
|
|
1765
|
+
this.diagnostics.push(diagnostic("VEL5056", `Component '${statement.name}' has more than one expose declaration`, statement.span));
|
|
1766
|
+
if (statement.handleType && exposes === 0)
|
|
1767
|
+
this.diagnostics.push(diagnostic("VEL5056", `Component '${statement.name}' declares an exposed Handle but does not provide an expose value`, statement.handleType.span));
|
|
1768
|
+
if (renderValue && isWebJsx(renderValue))
|
|
1769
|
+
this.validateComponentHost(renderValue, statement);
|
|
1770
|
+
this.componentStates = previousStates;
|
|
1771
|
+
this.explicitReadonlyPropBindings = previousExplicitReadonlyProps;
|
|
1772
|
+
this.flowFrameDepth -= 1;
|
|
1773
|
+
this.exitScope();
|
|
1774
|
+
this.constructorDepth = outerConstructorDepth;
|
|
1775
|
+
}
|
|
1776
|
+
containsReadonlyView(type) {
|
|
1777
|
+
const resolved = this.expandAliases(type);
|
|
1778
|
+
if (resolved.kind === "optional")
|
|
1779
|
+
return this.containsReadonlyView(resolved.inner);
|
|
1780
|
+
if (resolved.kind === "union")
|
|
1781
|
+
return resolved.members.some((member) => this.containsReadonlyView(member));
|
|
1782
|
+
return isReadonlyView(resolved);
|
|
1783
|
+
}
|
|
1784
|
+
validateComponentHandleType(type, sourceSpan) {
|
|
1785
|
+
const expanded = this.expandAliases(type);
|
|
1786
|
+
const fields = expanded.kind === "object"
|
|
1787
|
+
? expanded.fields
|
|
1788
|
+
: expanded.kind === "named" ? this.fieldsOf(expanded.identity ?? expanded.name) : null;
|
|
1789
|
+
if (!fields)
|
|
1790
|
+
this.diagnostics.push(diagnostic("VEL5056", `A component Handle must be a concrete record type, received ${describeType(type)}`, sourceSpan));
|
|
1791
|
+
}
|
|
1792
|
+
resolveAnnotation(reference) {
|
|
1793
|
+
return this.normalizeComponentContracts(super.resolveAnnotation(reference));
|
|
1794
|
+
}
|
|
1795
|
+
normalizeComponentContracts(type) {
|
|
1796
|
+
if (type.kind === "optional")
|
|
1797
|
+
return optionalOf(this.normalizeComponentContracts(type.inner));
|
|
1798
|
+
if (type.kind === "list" || type.kind === "set")
|
|
1799
|
+
return { ...type, element: this.normalizeComponentContracts(type.element) };
|
|
1800
|
+
if (type.kind === "map")
|
|
1801
|
+
return { ...type, key: this.normalizeComponentContracts(type.key), value: this.normalizeComponentContracts(type.value) };
|
|
1802
|
+
if (type.kind === "record")
|
|
1803
|
+
return { ...type, value: this.normalizeComponentContracts(type.value) };
|
|
1804
|
+
if (type.kind === "promise" || type.kind === "runtimeType")
|
|
1805
|
+
return { ...type, value: this.normalizeComponentContracts(type.value) };
|
|
1806
|
+
if (type.kind === "object")
|
|
1807
|
+
return { ...type, fields: new Map([...type.fields].map(([name, value]) => [name, this.normalizeComponentContracts(value)])) };
|
|
1808
|
+
if (type.kind === "function" || type.kind === "action" || type.kind === "intrinsic")
|
|
1809
|
+
return {
|
|
1810
|
+
...type,
|
|
1811
|
+
parameters: type.parameters.map((parameter) => this.normalizeComponentContracts(parameter)),
|
|
1812
|
+
...(type.rest ? { rest: this.normalizeComponentContracts(type.rest) } : {}),
|
|
1813
|
+
result: this.normalizeComponentContracts(type.result),
|
|
1814
|
+
};
|
|
1815
|
+
if (type.kind === "union")
|
|
1816
|
+
return { kind: "union", members: type.members.map((member) => this.normalizeComponentContracts(member)) };
|
|
1817
|
+
if (!isWebComponentType(type))
|
|
1818
|
+
return type;
|
|
1819
|
+
return normalizeWebComponentType(type, (value) => this.normalizeComponentContracts(value), (value) => this.normalizeComponentContracts(value));
|
|
1820
|
+
}
|
|
1821
|
+
validateComponentHost(render, component) {
|
|
1822
|
+
const hosts = [];
|
|
1823
|
+
const visit = (element) => {
|
|
1824
|
+
if (!/^[A-Z]/u.test(element.tag)) {
|
|
1825
|
+
for (const attribute of element.attributes)
|
|
1826
|
+
if (attribute.name === "host")
|
|
1827
|
+
hosts.push(attribute);
|
|
1828
|
+
}
|
|
1829
|
+
for (const child of element.children)
|
|
1830
|
+
if (child.kind === "ExtensionExpression:web:jsx")
|
|
1831
|
+
visit(child);
|
|
1832
|
+
};
|
|
1833
|
+
visit(render);
|
|
1834
|
+
if (hosts.length > 1)
|
|
1835
|
+
this.diagnostics.push(diagnostic("VEL5043", `Component '${component.name}' declares more than one host element`, hosts[1].span));
|
|
1836
|
+
for (const host of hosts)
|
|
1837
|
+
if (host.value !== null)
|
|
1838
|
+
this.diagnostics.push(diagnostic("VEL5043", "The host directive is a valueless marker", host.span));
|
|
1839
|
+
const directNativeRoot = render.tag !== "" && !/^[A-Z]/u.test(render.tag);
|
|
1840
|
+
const delegatedComponentRoot = /^[A-Z]/u.test(render.tag);
|
|
1841
|
+
if (!directNativeRoot && !delegatedComponentRoot && hosts.length === 0) {
|
|
1842
|
+
this.diagnostics.push(diagnostic("VEL5043", `Component '${component.name}' has multiple roots and must mark exactly one native element with 'host'`, render.span));
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
analyzeLookEntries(entries, insideTarget, nested, inheritedTerms, scopeKey = "") {
|
|
1846
|
+
for (const entry of entries) {
|
|
1847
|
+
if (entry.kind === "LookSpread") {
|
|
1848
|
+
const type = this.inferExpression(entry.value, { kind: "named", name: "Look" });
|
|
1849
|
+
this.requireAssignable(type, { kind: "named", name: "Look" }, entry.value.span);
|
|
1850
|
+
this.reportLookSnapshotReads(entry.value);
|
|
1851
|
+
if (nested)
|
|
1852
|
+
this.diagnostics.push(diagnostic("VEL5044", "Look composition is only valid at the outer level; compose first, then place the result in a condition or target", entry.span));
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
if (entry.kind === "LookIf") {
|
|
1856
|
+
this.inferLookCondition(entry.condition);
|
|
1857
|
+
this.reportLookSnapshotReads(entry.condition);
|
|
1858
|
+
const thenTerms = lookConditionTermCount(entry.condition);
|
|
1859
|
+
const elseTerms = lookConditionTermCount(entry.condition, true);
|
|
1860
|
+
if (inheritedTerms * Math.max(thenTerms, elseTerms) > LOOK_CONDITION_TERM_LIMIT) {
|
|
1861
|
+
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));
|
|
1862
|
+
}
|
|
1863
|
+
const signature = lookConditionSignature(entry.condition);
|
|
1864
|
+
this.analyzeLookEntries(entry.thenEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * thenTerms), `${scopeKey}&${signature}`);
|
|
1865
|
+
this.analyzeLookEntries(entry.elseEntries, insideTarget, true, Math.min(LOOK_CONDITION_TERM_LIMIT, inheritedTerms * elseTerms), `${scopeKey}&!${signature}`);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
if (entry.kind === "LookTarget") {
|
|
1869
|
+
if (!LOOK_TARGETS.has(entry.name)) {
|
|
1870
|
+
const nearest = nearestLookName(entry.name, LOOK_TARGETS);
|
|
1871
|
+
this.diagnostics.push(diagnostic("VEL5038", LOOK_HOOKS.has(entry.name)
|
|
1872
|
+
? `Use 'if @${entry.name}:'; '@${entry.name}' is an element state condition, not a pseudo-element target`
|
|
1873
|
+
: nearest
|
|
1874
|
+
? `Unknown Look target '@${entry.name}'; did you mean '@${nearest}'?`
|
|
1875
|
+
: `Unknown Look target '@${entry.name}'; Look targets are ${[...LOOK_TARGETS].map((name) => `@${name}`).join(", ")}`, entry.span));
|
|
1876
|
+
}
|
|
1877
|
+
if (insideTarget)
|
|
1878
|
+
this.diagnostics.push(diagnostic("VEL5038", "Look targets cannot be nested", entry.span));
|
|
1879
|
+
// A repeated target is reported once; its body then gets a private scope
|
|
1880
|
+
// so the properties inside are not reported a second time as duplicates.
|
|
1881
|
+
const repeated = !this.recordLookEntry(`${scopeKey}#target`, entry.name);
|
|
1882
|
+
if (repeated)
|
|
1883
|
+
this.diagnostics.push(diagnostic("VEL5039", `Look target '@${entry.name}' is defined more than once in the same scope`, entry.span));
|
|
1884
|
+
this.analyzeLookEntries(entry.entries, true, true, inheritedTerms, repeated ? `${scopeKey}@${entry.name}#${entry.span.start}` : `${scopeKey}@${entry.name}`);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (!this.recordLookEntry(scopeKey, entry.name)) {
|
|
1888
|
+
this.diagnostics.push(diagnostic("VEL5039", `Look property '${entry.name}' is defined more than once in the same scope`, entry.span));
|
|
1889
|
+
}
|
|
1890
|
+
if (!this.analyzeLookValue(entry.name, entry.value, entry.span, null))
|
|
1891
|
+
continue;
|
|
1892
|
+
const expected = LOOK_PROPERTY_TYPES.get(entry.name) ?? stringType;
|
|
1893
|
+
const actual = this.inferExpression(entry.value, expected);
|
|
1894
|
+
this.reportLookSnapshotReads(entry.value);
|
|
1895
|
+
// Zero is the one unitless CSS length, so `padding = 0` stays legal while
|
|
1896
|
+
// every other bare number is answered with the unit it needs (LOK-D3).
|
|
1897
|
+
if (mentionsLookUnitType(expected) && lookLiteralZero(entry.value))
|
|
1898
|
+
continue;
|
|
1899
|
+
if (this.reportLookNumberWithoutUnit(entry.name, actual, expected, entry.value.span))
|
|
1900
|
+
continue;
|
|
1901
|
+
if (actual.kind !== "null" && expected.kind !== "unknown")
|
|
1902
|
+
this.requireAssignable(actual, expected, entry.value.span);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* LOK-D1: a `look:` literal is constructed once, where it is written. Its
|
|
1907
|
+
* conditions and its values are snapshot positions — a reactive read inside
|
|
1908
|
+
* one compiles cleanly and then never updates, which is the quietest trap in
|
|
1909
|
+
* the visual language. The two spellings that stay live are the JSX
|
|
1910
|
+
* expression position and the `look:property` directive, so the read is
|
|
1911
|
+
* rejected here and both are taught.
|
|
1912
|
+
*/
|
|
1913
|
+
reportLookSnapshotReads(expression) {
|
|
1914
|
+
const visit = (value) => {
|
|
1915
|
+
if (Array.isArray(value)) {
|
|
1916
|
+
for (const item of value)
|
|
1917
|
+
visit(item);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (!value || typeof value !== "object")
|
|
1921
|
+
return;
|
|
1922
|
+
const record = value;
|
|
1923
|
+
if (record.kind === "IdentifierExpression" && typeof record.name === "string") {
|
|
1924
|
+
const name = record.name;
|
|
1925
|
+
const reactive = this.reactiveBindingKind(name) !== null || this.derivedReactiveRead(name);
|
|
1926
|
+
if (reactive) {
|
|
1927
|
+
this.diagnostics.push(diagnostic("VEL5058", `A Look literal is built once where it is written, so '${name}' is read as a snapshot and the visual never follows it. Put the reactive decision on the element instead: 'look={${name} ? oneLook : otherLook}' chooses a whole Look, and 'look:property={...}' sets one property`, record.span));
|
|
1928
|
+
}
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
for (const [key, child] of Object.entries(record))
|
|
1932
|
+
if (key !== "span")
|
|
1933
|
+
visit(child);
|
|
1934
|
+
};
|
|
1935
|
+
visit(expression);
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* True when a name both belongs to a derived reactive declaration and still
|
|
1939
|
+
* resolves to one here: a computed accessor is a zero-argument function, and a
|
|
1940
|
+
* resource or action handle is a record with its reactive fields. An ordinary
|
|
1941
|
+
* binding that happens to share the name — a function parameter, a local — is
|
|
1942
|
+
* not a reactive read.
|
|
1943
|
+
*/
|
|
1944
|
+
derivedReactiveRead(name) {
|
|
1945
|
+
if (!this.derivedReactiveNames.has(name))
|
|
1946
|
+
return false;
|
|
1947
|
+
const binding = this.lookup(name);
|
|
1948
|
+
if (!binding)
|
|
1949
|
+
return false;
|
|
1950
|
+
const type = this.expandAliases(binding.type);
|
|
1951
|
+
if (type.kind === "function" || type.kind === "action")
|
|
1952
|
+
return type.parameters.length === 0 && !type.rest;
|
|
1953
|
+
return type.kind === "object" && (type.fields.has("reload") || type.fields.has("pending"));
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* LOK-U8: the velar/look builders check their numeric domains at run time, so
|
|
1957
|
+
* a literal out-of-range colour used to compile clean and blank the page on
|
|
1958
|
+
* the first paint. Literal arguments are checked in the same terms while the
|
|
1959
|
+
* module compiles; dynamic arguments keep the runtime guard.
|
|
1960
|
+
*/
|
|
1961
|
+
checkLookBuilderCall(expression) {
|
|
1962
|
+
const builder = expression.callee.kind === "IdentifierExpression"
|
|
1963
|
+
? this.lookBuilderNames.get(expression.callee.name)
|
|
1964
|
+
: undefined;
|
|
1965
|
+
if (!builder)
|
|
1966
|
+
return;
|
|
1967
|
+
const key = spanIdentity(expression.span);
|
|
1968
|
+
if (this.checkedBuilderCalls.has(key))
|
|
1969
|
+
return;
|
|
1970
|
+
this.checkedBuilderCalls.add(key);
|
|
1971
|
+
if (builder === "animate") {
|
|
1972
|
+
this.checkAnimateBuilderCall(expression);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
const ranges = LOOK_BUILDER_NUMERIC_RANGES.get(builder);
|
|
1976
|
+
for (const [index, argument] of expression.arguments.entries()) {
|
|
1977
|
+
const named = expression.argumentNames?.[index] ?? null;
|
|
1978
|
+
const position = named ? -1 : index;
|
|
1979
|
+
const range = position >= 0 ? ranges?.[position] : undefined;
|
|
1980
|
+
const literal = argument.kind === "LiteralExpression" && typeof argument.value === "number" ? argument.value
|
|
1981
|
+
: argument.kind === "UnaryExpression" && argument.operator === "-" && argument.operand.kind === "LiteralExpression" && typeof argument.operand.value === "number"
|
|
1982
|
+
? -argument.operand.value : null;
|
|
1983
|
+
if (range && literal !== null && (literal < range[1] || literal > range[2])) {
|
|
1984
|
+
this.diagnostics.push(diagnostic("VEL5042", `${range[0]} must be from ${range[1]} through ${range[2]}; ${builder} received ${literal}`, argument.span));
|
|
1985
|
+
}
|
|
1986
|
+
// LOK-D3, builder half: a unitless number in a length position is dead
|
|
1987
|
+
// CSS exactly as it is on a property. Zero is the one unitless length.
|
|
1988
|
+
if (LOOK_LENGTH_BUILDERS.has(builder) && literal !== null && literal !== 0
|
|
1989
|
+
&& !(builder === "border" && position !== 0) && !(builder === "shadow" && position === 5)) {
|
|
1990
|
+
this.diagnostics.push(diagnostic("VEL5042", `${builder} composes CSS lengths, so ${literal} requires a unit; write a unit value such as ${literal}px or ${literal}rem (only 0 is unitless)`, argument.span));
|
|
1991
|
+
}
|
|
1992
|
+
if (builder === "border" && position === 2 && argument.kind === "LiteralExpression" && typeof argument.value === "string"
|
|
1993
|
+
&& !LOOK_BORDER_STYLE_NAMES.has(argument.value)) {
|
|
1994
|
+
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));
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (builder === "tracks" && expression.arguments.length > 1024) {
|
|
1998
|
+
this.diagnostics.push(diagnostic("VEL5042", "tracks cannot contain more than 1024 values", expression.span));
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
checkAnimateBuilderCall(expression) {
|
|
2002
|
+
const argument = (name, position) => {
|
|
2003
|
+
const named = expression.argumentNames?.findIndex((candidate) => candidate === name) ?? -1;
|
|
2004
|
+
if (named >= 0)
|
|
2005
|
+
return expression.arguments[named] ?? null;
|
|
2006
|
+
const sourceName = expression.argumentNames?.[position];
|
|
2007
|
+
return sourceName === null || sourceName === undefined ? expression.arguments[position] ?? null : null;
|
|
2008
|
+
};
|
|
2009
|
+
const duration = argument("duration", 1);
|
|
2010
|
+
const delay = argument("delay", 3);
|
|
2011
|
+
const count = argument("count", 4);
|
|
2012
|
+
const loop = argument("loop", 5);
|
|
2013
|
+
const easing = argument("easing", 2);
|
|
2014
|
+
const direction = argument("direction", 6);
|
|
2015
|
+
const fill = argument("fill", 7);
|
|
2016
|
+
const durationValue = lookDurationLiteral(duration);
|
|
2017
|
+
const delayValue = lookDurationLiteral(delay);
|
|
2018
|
+
if (durationValue !== null && durationValue <= 0) {
|
|
2019
|
+
this.diagnostics.push(diagnostic("VEL5060", "Animation duration must be greater than zero", duration.span));
|
|
2020
|
+
}
|
|
2021
|
+
if (delayValue !== null && delayValue < 0) {
|
|
2022
|
+
this.diagnostics.push(diagnostic("VEL5060", "Animation delay cannot be negative", delay.span));
|
|
2023
|
+
}
|
|
2024
|
+
const countValue = numericLiteral(count);
|
|
2025
|
+
if (countValue !== null && (!Number.isInteger(countValue) || countValue <= 0 || countValue > 1_000_000)) {
|
|
2026
|
+
this.diagnostics.push(diagnostic("VEL5060", "Animation count must be a positive integer no greater than 1000000", count.span));
|
|
2027
|
+
}
|
|
2028
|
+
if (count && loop) {
|
|
2029
|
+
this.diagnostics.push(diagnostic("VEL5060", "animate accepts either count or loop, not both; use loop=true for an infinite animation", expression.span));
|
|
2030
|
+
}
|
|
2031
|
+
this.checkAnimationKeyword(easing, "easing", LOOK_ANIMATION_EASINGS);
|
|
2032
|
+
this.checkAnimationKeyword(direction, "direction", LOOK_ANIMATION_DIRECTIONS);
|
|
2033
|
+
this.checkAnimationKeyword(fill, "fill", LOOK_ANIMATION_FILLS);
|
|
2034
|
+
}
|
|
2035
|
+
checkAnimationKeyword(value, name, vocabulary) {
|
|
2036
|
+
if (!value || value.kind !== "LiteralExpression" || typeof value.value !== "string")
|
|
2037
|
+
return;
|
|
2038
|
+
if (!vocabulary.has(value.value)) {
|
|
2039
|
+
this.diagnostics.push(diagnostic("VEL5060", `Animation ${name} '${value.value}' is not supported; use one of ${[...vocabulary].join(", ")}`, value.span));
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
analyzeKeyframes(expression) {
|
|
2043
|
+
for (const stop of expression.stops) {
|
|
2044
|
+
const properties = new Set();
|
|
2045
|
+
for (const entry of stop.entries) {
|
|
2046
|
+
if (properties.has(entry.name)) {
|
|
2047
|
+
this.diagnostics.push(diagnostic("VEL5039", `Keyframe property '${entry.name}' is defined more than once at the same stop`, entry.span));
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
properties.add(entry.name);
|
|
2051
|
+
if (LOOK_NON_ANIMATABLE_PROPERTIES.has(entry.name)) {
|
|
2052
|
+
this.diagnostics.push(diagnostic("VEL5060", `Look property '${entry.name}' does not participate in animation interpolation`, entry.span));
|
|
2053
|
+
this.inferExpression(entry.value);
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
if (!this.analyzeLookValue(entry.name, entry.value, entry.span, null))
|
|
2057
|
+
continue;
|
|
2058
|
+
const expected = LOOK_PROPERTY_TYPES.get(entry.name) ?? stringType;
|
|
2059
|
+
const actual = this.inferExpression(entry.value, expected);
|
|
2060
|
+
this.reportKeyframeSnapshotReads(entry.value);
|
|
2061
|
+
if (keyframeCssValue(entry.value, this.lookStaticValues) === null) {
|
|
2062
|
+
this.diagnostics.push(diagnostic("VEL5060", "A keyframe value must resolve to static CSS from literals, unit values, arithmetic, or velar/look builders", entry.value.span));
|
|
2063
|
+
}
|
|
2064
|
+
if (mentionsLookUnitType(expected) && lookLiteralZero(entry.value))
|
|
2065
|
+
continue;
|
|
2066
|
+
if (this.reportLookNumberWithoutUnit(entry.name, actual, expected, entry.value.span))
|
|
2067
|
+
continue;
|
|
2068
|
+
if (actual.kind !== "null" && expected.kind !== "unknown")
|
|
2069
|
+
this.requireAssignable(actual, expected, entry.value.span);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
reportKeyframeSnapshotReads(expression) {
|
|
2074
|
+
const visit = (value) => {
|
|
2075
|
+
if (Array.isArray(value)) {
|
|
2076
|
+
value.forEach(visit);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
if (!value || typeof value !== "object")
|
|
2080
|
+
return;
|
|
2081
|
+
const record = value;
|
|
2082
|
+
if (record.kind === "IdentifierExpression" && typeof record.name === "string") {
|
|
2083
|
+
const name = record.name;
|
|
2084
|
+
if (this.reactiveBindingKind(name) !== null || this.derivedReactiveRead(name)) {
|
|
2085
|
+
this.diagnostics.push(diagnostic("VEL5060", `Keyframes generate static CSS, so reactive '${name}' cannot be read inside a stop; make animation presence dynamic with look:animation={condition ? animate(frames, 1s) : null}`, record.span));
|
|
2086
|
+
}
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
for (const [key, child] of Object.entries(record))
|
|
2090
|
+
if (key !== "span")
|
|
2091
|
+
visit(child);
|
|
2092
|
+
};
|
|
2093
|
+
visit(expression);
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Records one entry in its lowered scope (condition signature plus target) so
|
|
2097
|
+
* two sibling blocks with the same condition report the property they both
|
|
2098
|
+
* set. Returns false when the entry repeats. LOK-I4: the charter's duplicate
|
|
2099
|
+
* promise used to hold only inside a single indented scope.
|
|
2100
|
+
*/
|
|
2101
|
+
recordLookEntry(scopeKey, name) {
|
|
2102
|
+
const seen = this.lookEntryScopes.get(scopeKey) ?? new Set();
|
|
2103
|
+
this.lookEntryScopes.set(scopeKey, seen);
|
|
2104
|
+
if (seen.has(name))
|
|
2105
|
+
return false;
|
|
2106
|
+
seen.add(name);
|
|
2107
|
+
return true;
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* The vocabulary checks a Look property shares between the block spelling and
|
|
2111
|
+
* the `look:`/`style:` directives. Returns false when the entry is already
|
|
2112
|
+
* reported and its value needs no further checking. LOK-I1: an unrecognized
|
|
2113
|
+
* property no longer co-reports a `stringType` fallback assignment error.
|
|
2114
|
+
*/
|
|
2115
|
+
analyzeLookValue(name, value, entrySpan, directive) {
|
|
2116
|
+
const inline = directive !== null;
|
|
2117
|
+
const label = directive === "style" ? "inline Style" : directive === "look" ? "inline Look" : "Look";
|
|
2118
|
+
if (name === "animation" && value.kind === "LiteralExpression" && typeof value.value === "string") {
|
|
2119
|
+
this.diagnostics.push(diagnostic("VEL5038", "Look animation does not accept CSS shorthand text; declare a checked 'keyframes:' value and pass animate(frames, duration, ...) instead", entrySpan));
|
|
2120
|
+
if (!inline)
|
|
2121
|
+
this.inferExpression(value);
|
|
2122
|
+
return false;
|
|
2123
|
+
}
|
|
2124
|
+
if (!LOOK_PROPERTIES.has(name)) {
|
|
2125
|
+
const nearest = nearestLookName(name, LOOK_PROPERTIES);
|
|
2126
|
+
const exclusion = LOOK_EXCLUDED_PROPERTIES.get(name);
|
|
2127
|
+
this.diagnostics.push(diagnostic("VEL5038", exclusion
|
|
2128
|
+
? `CSS property '${name}' is outside checked Look: ${exclusion}. Use a module-level 'import css unsafe "./styles.css" before look' when that boundary is intentional`
|
|
2129
|
+
: nearest
|
|
2130
|
+
? `Unknown ${label} property '${name}'; did you mean '${nearest}'?`
|
|
2131
|
+
: `Unknown ${label} property '${name}'; ${inline ? `${directive}:* uses the same camelCase property names as a Look block` : "Look properties use the DOM camelCase spelling of a CSS property"}`, entrySpan));
|
|
2132
|
+
if (!inline)
|
|
2133
|
+
this.inferExpression(value);
|
|
2134
|
+
return false;
|
|
2135
|
+
}
|
|
2136
|
+
const shorthandGuidance = lookShorthandStringGuidance(name, value);
|
|
2137
|
+
if (shorthandGuidance) {
|
|
2138
|
+
this.diagnostics.push(diagnostic("VEL5038", shorthandGuidance, value.span));
|
|
2139
|
+
return false;
|
|
2140
|
+
}
|
|
2141
|
+
if (!this.validateLookStringVocabulary(name, value))
|
|
2142
|
+
return false;
|
|
2143
|
+
return true;
|
|
2144
|
+
}
|
|
2145
|
+
validateLookStringVocabulary(name, value) {
|
|
2146
|
+
const kind = LOOK_PROPERTY_VALUE_KINDS.get(name);
|
|
2147
|
+
if (!kind || kind === "text" || kind === "filter" || kind === "transform" || kind === "animation")
|
|
2148
|
+
return true;
|
|
2149
|
+
const values = literalLookStrings(value);
|
|
2150
|
+
if (values === null)
|
|
2151
|
+
return true;
|
|
2152
|
+
for (const text of values) {
|
|
2153
|
+
const normalized = text.trim();
|
|
2154
|
+
if (kind === "metric" && /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|vw|vh|vmin|vmax|%|fr|ms|s|deg|turn)$/u.test(normalized)) {
|
|
2155
|
+
this.diagnostics.push(diagnostic("VEL5038", `Use the unit literal ${normalized}; quoted unit values are not part of Look`, value.span));
|
|
2156
|
+
return false;
|
|
2157
|
+
}
|
|
2158
|
+
let accepted;
|
|
2159
|
+
// A metric property is a unit value plus a keyword half: its own CSS
|
|
2160
|
+
// keywords when it has a table -- `backgroundSize` takes lengths *and*
|
|
2161
|
+
// cover and contain, the `<position>` properties take the placement words
|
|
2162
|
+
// (D60 rule 150, D67 rule 172) -- and the shared sizing words when it has
|
|
2163
|
+
// none. The two are alternatives rather than a union, for the reason D65
|
|
2164
|
+
// rule 168 gave for the keyword kind: a shared list added on top of a
|
|
2165
|
+
// real table decides values for a property it has never heard of, so
|
|
2166
|
+
// `objectPosition = "min-content"` would compile and reach the browser as
|
|
2167
|
+
// a declaration it discards.
|
|
2168
|
+
const metricKeywords = LOOK_PROPERTY_KEYWORDS.get(name);
|
|
2169
|
+
if (kind === "metric")
|
|
2170
|
+
accepted = metricKeywords === undefined ? lookMetricKeywords.has(normalized) : metricKeywords.has(normalized);
|
|
2171
|
+
else if (kind === "color" || kind === "background")
|
|
2172
|
+
accepted = lookColorKeywords.has(normalized) || /^#[0-9a-f]{3,8}$/iu.test(normalized);
|
|
2173
|
+
else if (kind === "image")
|
|
2174
|
+
accepted = lookCssWideKeywords.has(normalized) || normalized === "none";
|
|
2175
|
+
// D73 rule 187: every kind that decides a string keyword reads the
|
|
2176
|
+
// property's own closed set, and look.ts refuses to load if one is
|
|
2177
|
+
// missing. The 46-word shared list this replaces both admitted values the
|
|
2178
|
+
// property never had and made the refusal describe a table that did not
|
|
2179
|
+
// exist.
|
|
2180
|
+
else
|
|
2181
|
+
accepted = LOOK_PROPERTY_KEYWORDS.get(name)?.has(normalized) === true;
|
|
2182
|
+
if (!accepted) {
|
|
2183
|
+
// D67 rule 174, widened by D73 rule 187: every refusal now names the
|
|
2184
|
+
// property's own values, because every one of these kinds has them. The
|
|
2185
|
+
// lead says what the non-keyword half of the property is, so a reader
|
|
2186
|
+
// who wanted a length, an angle or a builder is not sent looking through
|
|
2187
|
+
// a keyword list for it.
|
|
2188
|
+
const expected = kind === "metric"
|
|
2189
|
+
? lookVocabularyGuidance(name, normalized, metricKeywords === undefined ? lookSharedMetricKeywords : lookOwnKeywords(name), "write a unit value such as 16px, 1rem or 50%, or one of")
|
|
2190
|
+
: kind === "color" || kind === "background"
|
|
2191
|
+
? { text: "use a checked color() or color keyword", named: false }
|
|
2192
|
+
: kind === "image"
|
|
2193
|
+
? { text: "use a checked image builder such as linearGradient() or asset()", named: false }
|
|
2194
|
+
: lookVocabularyGuidance(name, normalized, lookOwnKeywords(name), lookVocabularyLead(kind));
|
|
2195
|
+
// D65 rule 169: a property whose CSS value space no set can hold says
|
|
2196
|
+
// what it left out, so the boundary is legible where it is met.
|
|
2197
|
+
const partial = expected.named ? undefined : LOOK_PARTIAL_KEYWORD_PROPERTIES.get(name);
|
|
2198
|
+
this.diagnostics.push(diagnostic("VEL5038", `Look property '${name}' does not accept '${normalized}'; ${expected.text}${partial
|
|
2199
|
+
? `. ${partial}; use a module-level 'import css unsafe "./styles.css" before look' when that boundary is intentional`
|
|
2200
|
+
: ""}`, value.span));
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return true;
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* LOK-D3: a bare number on a length property reaches CSS as a declaration the
|
|
2208
|
+
* browser discards. The union already rejects it; this diagnostic replaces the
|
|
2209
|
+
* union dump with the unit the author meant to write.
|
|
2210
|
+
*/
|
|
2211
|
+
reportLookNumberWithoutUnit(name, actual, expected, valueSpan) {
|
|
2212
|
+
if (this.expandAliases(actual).kind !== "number" || LOOK_UNITLESS_PROPERTIES.has(name))
|
|
2213
|
+
return false;
|
|
2214
|
+
if (!mentionsLookUnitType(expected))
|
|
2215
|
+
return false;
|
|
2216
|
+
this.diagnostics.push(diagnostic("VEL5038", `Look property '${name}' is a CSS length and requires a unit; write a unit value such as 16px, 1rem, or 50%`, valueSpan));
|
|
2217
|
+
return true;
|
|
2218
|
+
}
|
|
2219
|
+
inferLookCondition(expression) {
|
|
2220
|
+
if (isWebExpression(expression) && expression.kind === "ExtensionExpression:web:look-hook") {
|
|
2221
|
+
if (!LOOK_HOOKS.has(expression.name)) {
|
|
2222
|
+
// LOK-I2: the target position redirects a hook to 'if @hook:', so the
|
|
2223
|
+
// condition position redirects a target to its block spelling.
|
|
2224
|
+
const nearest = nearestLookName(expression.name, LOOK_HOOKS);
|
|
2225
|
+
this.diagnostics.push(diagnostic("VEL5038", LOOK_TARGETS.has(expression.name)
|
|
2226
|
+
? `Use '@${expression.name}:' as a target block; '@${expression.name}' is a pseudo-element target, not an element state condition`
|
|
2227
|
+
: nearest
|
|
2228
|
+
? `Unknown Look hook '@${expression.name}'; did you mean '@${nearest}'?`
|
|
2229
|
+
: `Unknown Look hook '@${expression.name}'; Look hooks are ${[...LOOK_HOOKS].map((name) => `@${name}`).join(", ")}`, expression.span));
|
|
2230
|
+
}
|
|
2231
|
+
return boolType;
|
|
2232
|
+
}
|
|
2233
|
+
if (expression.kind === "UnaryExpression" && expression.operator === "not") {
|
|
2234
|
+
const value = this.inferLookCondition(expression.operand);
|
|
2235
|
+
this.requireCondition(value, expression.operand);
|
|
2236
|
+
return boolType;
|
|
2237
|
+
}
|
|
2238
|
+
if (expression.kind === "BinaryExpression" && (expression.operator === "and" || expression.operator === "or")) {
|
|
2239
|
+
const left = this.inferLookCondition(expression.left);
|
|
2240
|
+
const right = this.inferLookCondition(expression.right);
|
|
2241
|
+
this.requireCondition(left, expression.left);
|
|
2242
|
+
this.requireCondition(right, expression.right);
|
|
2243
|
+
return boolType;
|
|
2244
|
+
}
|
|
2245
|
+
if (isViewportComparison(expression)) {
|
|
2246
|
+
const comparison = expression;
|
|
2247
|
+
this.inferExpression(comparison.right, lookLength);
|
|
2248
|
+
this.checkViewportThreshold(comparison.right);
|
|
2249
|
+
return boolType;
|
|
2250
|
+
}
|
|
2251
|
+
const flipped = flippedViewportComparison(expression);
|
|
2252
|
+
if (flipped) {
|
|
2253
|
+
this.inferExpression(flipped.threshold, lookLength);
|
|
2254
|
+
this.diagnostics.push(diagnostic("VEL5052", `Write the viewport on the left of a breakpoint: 'viewport.${flipped.property} ${flipped.operator} ${lookSourceOf(flipped.threshold)}'`, expression.span));
|
|
2255
|
+
this.checkViewportThreshold(flipped.threshold);
|
|
2256
|
+
return boolType;
|
|
2257
|
+
}
|
|
2258
|
+
if (isSchemeCondition(expression))
|
|
2259
|
+
return boolType;
|
|
2260
|
+
// LOK-U3: the media-subject set is closed. A subject the reader reached for
|
|
2261
|
+
// and Look does not carry names the whole set instead of reporting the
|
|
2262
|
+
// subject as an unknown Core name. A comparison carries its subject on
|
|
2263
|
+
// either side, so both operands are examined.
|
|
2264
|
+
const operands = expression.kind === "BinaryExpression" ? [expression.left, expression.right] : [expression];
|
|
2265
|
+
for (const operand of operands) {
|
|
2266
|
+
const subject = mediaSubjectShape(operand);
|
|
2267
|
+
if (!subject || this.lookup(subject.subject))
|
|
2268
|
+
continue;
|
|
2269
|
+
if (!LOOK_MEDIA_SUBJECTS.has(subject.subject) && !LOOK_ABSENT_MEDIA_SUBJECTS.has(subject.subject))
|
|
2270
|
+
continue;
|
|
2271
|
+
this.diagnostics.push(diagnostic("VEL5038", `Look media conditions are ${lookMediaVocabulary()}; '${subject.subject}.${subject.feature}' is not one of them`, expression.span));
|
|
2272
|
+
return boolType;
|
|
2273
|
+
}
|
|
2274
|
+
const type = this.inferExpression(expression);
|
|
2275
|
+
this.requireCondition(type, expression);
|
|
2276
|
+
return boolType;
|
|
2277
|
+
}
|
|
2278
|
+
checkViewportThreshold(value) {
|
|
2279
|
+
const threshold = evaluateLookStaticExpression(value, this.lookStaticValues);
|
|
2280
|
+
if (threshold?.kind !== "unit") {
|
|
2281
|
+
this.diagnostics.push(diagnostic("VEL5052", "A viewport breakpoint must resolve at compile time to a px, rem, or em value; use a const unit token or an imported const unit token", value.span));
|
|
2282
|
+
}
|
|
2283
|
+
else if (!LOOK_MEDIA_LENGTH_UNITS.has(threshold.unit)) {
|
|
2284
|
+
this.diagnostics.push(diagnostic("VEL5052", `Viewport breakpoints do not support '${threshold.unit}'; use px, rem, or em`, value.span));
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
inferJsx(expression) {
|
|
2288
|
+
this.jsxDepth += 1;
|
|
2289
|
+
const attributes = new Map(expression.attributes.map((attribute) => [attribute.name, attribute]));
|
|
2290
|
+
const component = /^[A-Z]/u.test(expression.tag);
|
|
2291
|
+
if (expression.tag && !component && !WEB_NATIVE_ELEMENTS.has(expression.tag) && !isWebCustomElementName(expression.tag)) {
|
|
2292
|
+
const nearest = nearestLookName(expression.tag, WEB_NATIVE_ELEMENTS);
|
|
2293
|
+
this.diagnostics.push(diagnostic("VEL5061", nearest
|
|
2294
|
+
? `Unknown native element '<${expression.tag}>'; did you mean '<${nearest}>'?`
|
|
2295
|
+
: `Unknown native element '<${expression.tag}>'; use a standard HTML, SVG, or MathML element, or a lowercase hyphenated custom element such as '<user-card>'`, expression.tagSpan));
|
|
2296
|
+
}
|
|
2297
|
+
if (attributes.size !== expression.attributes.length)
|
|
2298
|
+
this.diagnostics.push(diagnostic("VEL5014", `JSX element '${expression.tag}' has duplicate attributes`, expression.span));
|
|
2299
|
+
for (const attribute of expression.attributes) {
|
|
2300
|
+
if (removedJsxControlAttributes.has(attribute.name)) {
|
|
2301
|
+
this.diagnostics.push(diagnostic("VEL5029", `JSX '${attribute.name}' was removed; branch with an ordinary expression such as {condition ? <A /> : <B />}`, attribute.span));
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
if (expression.tag && !component) {
|
|
2305
|
+
for (const attribute of expression.attributes)
|
|
2306
|
+
if (!removedJsxControlAttributes.has(attribute.name))
|
|
2307
|
+
this.analyzeNativeJsxAttribute(expression, attribute);
|
|
2308
|
+
if (attributes.has("unsafe:html") && expression.children.length > 0)
|
|
2309
|
+
this.diagnostics.push(diagnostic("VEL5015", "unsafe:html cannot be combined with JSX children", expression.span));
|
|
2310
|
+
if (expression.tag === "img" && !attributes.has("alt"))
|
|
2311
|
+
this.diagnostics.push(diagnostic("VEL5016", "An img element requires an alt attribute", expression.span));
|
|
2312
|
+
if (expression.tag === "button" && !attributes.has("aria-label") && !attributes.has("aria-labelledby") && !hasAccessibleJsxContent(expression))
|
|
2313
|
+
this.diagnostics.push(diagnostic("VEL5026", "A button requires text content, aria-label, or aria-labelledby", expression.span));
|
|
2314
|
+
if (expression.tag === "svg" && !hasAccessibleSvgName(expression))
|
|
2315
|
+
this.diagnostics.push(diagnostic("VEL5030", "An svg element requires a non-empty title, aria-label, aria-labelledby, or aria-hidden='true'", expression.span));
|
|
2316
|
+
if (expression.tag === "a" && !attributes.has("href"))
|
|
2317
|
+
this.diagnostics.push(diagnostic("VEL5027", "A native anchor requires href; use a button for actions", expression.span));
|
|
2318
|
+
const target = attributes.get("target")?.value;
|
|
2319
|
+
const relation = attributes.get("rel")?.value;
|
|
2320
|
+
if (expression.tag === "a" && target === "_blank" && (typeof relation !== "string" || !relation.split(/\s+/u).includes("noopener"))) {
|
|
2321
|
+
this.diagnostics.push(diagnostic("VEL5028", "An anchor with target='_blank' requires rel='noopener'", expression.span));
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
if (component)
|
|
2325
|
+
this.analyzeComponentElement(expression);
|
|
2326
|
+
const key = expression.attributes.find((attribute) => attribute.name === "key");
|
|
2327
|
+
if (key)
|
|
2328
|
+
this.staticJsxKeys.push({ element: expression, attribute: key });
|
|
2329
|
+
for (const child of expression.children) {
|
|
2330
|
+
if (child.kind === "JSXExpressionChild") {
|
|
2331
|
+
// The keyed recognizer is purely syntactic, so the honored roots of this
|
|
2332
|
+
// interpolation are known before its expression is inferred; a nested
|
|
2333
|
+
// element then knows whether its own key is honored (WEB-C1).
|
|
2334
|
+
this.checkKeyedInterpolation(child.expression);
|
|
2335
|
+
const childType = this.inferExpression(child.expression);
|
|
2336
|
+
if (containsPromise(this.expandAliases(childType)))
|
|
2337
|
+
this.diagnostics.push(diagnostic("VEL5031", "JSX cannot render a Promise; await it before rendering", child.expression.span));
|
|
2338
|
+
else if (!isInvalidType(childType) && !(child.expression.kind === "ListExpression" && child.expression.elements.length === 0)
|
|
2339
|
+
&& !this.isJsxRenderable(childType)) {
|
|
2340
|
+
this.diagnostics.push(diagnostic("VEL5047", `JSX can render only text, finite numbers, bool, enums, WebNode values, and Lists of those values; received ${describeType(childType)}`, child.expression.span));
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
else if (child.kind === "ExtensionExpression:web:jsx") {
|
|
2344
|
+
this.inferJsx(child);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
this.jsxDepth -= 1;
|
|
2348
|
+
return webNodeType;
|
|
2349
|
+
}
|
|
2350
|
+
// Mirrors the emitter's keyed-children recognizer (dynamicChildLeaves): a
|
|
2351
|
+
// leaf shaped `source.map(item => <… key=… />)` — either the interpolation
|
|
2352
|
+
// itself or a '?:' branch of it — compiles to the identity-preserving keyed
|
|
2353
|
+
// path. A map leaf without a key must gain one (VEL5017), and a key that
|
|
2354
|
+
// sits anywhere else in the interpolation would be silently ignored at
|
|
2355
|
+
// runtime, so it is diagnosed instead of quietly rebuilding every child.
|
|
2356
|
+
checkKeyedInterpolation(expression) {
|
|
2357
|
+
const honoredKeyRoots = new Set();
|
|
2358
|
+
for (const leaf of dynamicChildLeaves(expression)) {
|
|
2359
|
+
if (!leaf.list)
|
|
2360
|
+
continue;
|
|
2361
|
+
if (leaf.list.key)
|
|
2362
|
+
honoredKeyRoots.add(leaf.list.arrow.body);
|
|
2363
|
+
else
|
|
2364
|
+
this.diagnostics.push(diagnostic("VEL5017", "A JSX list rendered with .map() requires a key on its root element", leaf.list.arrow.body.span));
|
|
2365
|
+
}
|
|
2366
|
+
for (const root of honoredKeyRoots)
|
|
2367
|
+
this.honoredJsxKeys.add(root);
|
|
2368
|
+
this.reportIneffectiveJsxKeys(expression, honoredKeyRoots);
|
|
2369
|
+
}
|
|
2370
|
+
// Walks one interpolation expression looking for `key` attributes that the
|
|
2371
|
+
// keyed fast path will never read. The walk stops at every JSX element:
|
|
2372
|
+
// an element's own children and attribute values are separate render sites
|
|
2373
|
+
// that receive their own checks when analysis recurses into them.
|
|
2374
|
+
reportIneffectiveJsxKeys(value, honored) {
|
|
2375
|
+
if (!value || typeof value !== "object")
|
|
2376
|
+
return;
|
|
2377
|
+
const record = value;
|
|
2378
|
+
if (record.kind === "ExtensionExpression:web:jsx") {
|
|
2379
|
+
const element = record;
|
|
2380
|
+
if (honored.has(element))
|
|
2381
|
+
return;
|
|
2382
|
+
const key = element.attributes.find((attribute) => attribute.name === "key");
|
|
2383
|
+
if (key) {
|
|
2384
|
+
this.reportedJsxKeys.add(element);
|
|
2385
|
+
this.diagnostics.push(diagnostic("VEL5050", "This JSX key has no effect: keys reuse children by identity only when the interpolation is 'items.map(item => <Row key={item.id} />)' or a '?:' branch of one; every other shape rebuilds its children on change — restructure the interpolation into that shape or remove the key", key.span));
|
|
2386
|
+
}
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
for (const child of Object.values(record)) {
|
|
2390
|
+
if (Array.isArray(child))
|
|
2391
|
+
child.forEach((item) => this.reportIneffectiveJsxKeys(item, honored));
|
|
2392
|
+
else
|
|
2393
|
+
this.reportIneffectiveJsxKeys(child, honored);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
analyzeComponentElement(expression) {
|
|
2397
|
+
const component = this.inferExpression({ kind: "IdentifierExpression", name: expression.tag, span: expression.tagSpan });
|
|
2398
|
+
if (!isWebComponentType(component)) {
|
|
2399
|
+
this.diagnostics.push(diagnostic("VEL5011", `Unknown component '${expression.tag}'`, expression.span));
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
const provided = new Set(expression.attributes.filter((attribute) => attribute.name !== "key" && attribute.name !== "ref" && !removedJsxControlAttributes.has(attribute.name)).map((attribute) => attribute.name));
|
|
2403
|
+
const hasChildren = expression.children.some((child) => child.kind !== "JSXText" || child.value.trim().length > 0);
|
|
2404
|
+
if (hasChildren && provided.has("children"))
|
|
2405
|
+
this.diagnostics.push(diagnostic("VEL5014", `Component '${expression.tag}' receives children both as a prop and as JSX content`, expression.span));
|
|
2406
|
+
else if (hasChildren && !component.properties.has("children"))
|
|
2407
|
+
this.diagnostics.push(diagnostic("VEL5018", `Component '${expression.tag}' does not declare JSX children`, expression.span));
|
|
2408
|
+
else if (hasChildren) {
|
|
2409
|
+
provided.add("children");
|
|
2410
|
+
this.requireAssignable(webNodeType, component.properties.get("children"), expression.span);
|
|
2411
|
+
}
|
|
2412
|
+
for (const required of component.requiredProperties) {
|
|
2413
|
+
if (provided.has(required))
|
|
2414
|
+
continue;
|
|
2415
|
+
// D31-26 promised that `children: WebNode?` is the omittable form; the
|
|
2416
|
+
// implementation makes a prop omittable through its default value, so the
|
|
2417
|
+
// diagnostic teaches the spelling that actually omits (WEB-N4).
|
|
2418
|
+
const declared = component.properties.get(required);
|
|
2419
|
+
const optionalDeclaration = declared !== undefined && this.expandAliases(declared).kind === "optional";
|
|
2420
|
+
this.diagnostics.push(diagnostic("VEL5012", optionalDeclaration
|
|
2421
|
+
? `Component '${expression.tag}' requires prop '${required}'; a prop becomes omittable through its default value — declare '${required}: ${describeType(declared)} = null' on the component`
|
|
2422
|
+
: `Component '${expression.tag}' requires prop '${required}'`, expression.span));
|
|
2423
|
+
}
|
|
2424
|
+
for (const attribute of expression.attributes) {
|
|
2425
|
+
if (removedJsxControlAttributes.has(attribute.name))
|
|
2426
|
+
continue;
|
|
2427
|
+
if (attribute.name === "key") {
|
|
2428
|
+
const key = typeof attribute.value === "string" ? stringType : attribute.value ? this.inferExpression(attribute.value) : boolType;
|
|
2429
|
+
if (!isInvalidType(key) && key.kind !== "string" && key.kind !== "number" && key.kind !== "enum" && key.kind !== "enumMember" && key.kind !== "any")
|
|
2430
|
+
this.diagnostics.push(diagnostic("VEL5022", "A JSX key must be a string, string-backed enum, or number", attribute.span));
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
if (attribute.name === "ref") {
|
|
2434
|
+
this.analyzeComponentRef(expression, attribute, component);
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
const expected = component.properties.get(attribute.name);
|
|
2438
|
+
if (attribute.name === "look") {
|
|
2439
|
+
this.analyzeJsxLookAttribute(attribute);
|
|
2440
|
+
continue;
|
|
2441
|
+
}
|
|
2442
|
+
if (attribute.name.startsWith("look:")) {
|
|
2443
|
+
const actual = typeof attribute.value === "string" ? stringType : attribute.value ? this.inferExpression(attribute.value) : boolType;
|
|
2444
|
+
this.analyzeInlineVisualAttribute(attribute, actual, "look");
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
if (attribute.name === "style") {
|
|
2448
|
+
if (attribute.value && typeof attribute.value !== "string")
|
|
2449
|
+
this.inferExpression(attribute.value);
|
|
2450
|
+
this.diagnostics.push(diagnostic("VEL5041", "Raw JSX style is not supported; use style:property for a checked high-priority inline override, or prefer Look for ordinary visuals", attribute.span));
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
if (attribute.name.startsWith("style:")) {
|
|
2454
|
+
const actual = typeof attribute.value === "string" ? stringType : attribute.value ? this.inferExpression(attribute.value) : boolType;
|
|
2455
|
+
this.analyzeInlineVisualAttribute(attribute, actual, "style");
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
if (attribute.name === "class") {
|
|
2459
|
+
const actual = typeof attribute.value === "string" ? stringType : attribute.value ? this.inferExpression(attribute.value) : boolType;
|
|
2460
|
+
if (!this.isClassInput(actual))
|
|
2461
|
+
this.diagnostics.push(diagnostic("VEL5040", `JSX class requires string, string?, or a list of strings; received ${describeType(actual)}`, attribute.span));
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (!expected) {
|
|
2465
|
+
this.diagnostics.push(diagnostic("VEL5013", `Component '${expression.tag}' has no prop '${attribute.name}'`, attribute.span));
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
this.semanticJsxAttributeOwners.set(`${attribute.span.start}:${attribute.name}`, component);
|
|
2469
|
+
// D51 rule 108: a JSX attribute is a typed position, exactly like an
|
|
2470
|
+
// argument position, so the declared prop type is the literal's context.
|
|
2471
|
+
// Without it `items={[]}` inferred List<unknown> and forced the author to
|
|
2472
|
+
// annotate an empty list on a separate line.
|
|
2473
|
+
const actual = typeof attribute.value === "string" ? stringType : attribute.value ? this.inferExpression(attribute.value, expected) : boolType;
|
|
2474
|
+
if (isWebComponentConstructor(component) && webComponentIntrinsic(component) === "web.router" && attribute.name === "fallback" && actual.kind !== "null" && actual.kind !== "any") {
|
|
2475
|
+
this.checkWebRouteComponent(actual, attribute.span, "A Router fallback");
|
|
2476
|
+
}
|
|
2477
|
+
this.requireAssignable(actual, expected, attribute.span);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* The `look=` attribute on either host kind. A Look block written inline is
|
|
2482
|
+
* reported without inferring its entries, so the one directive-level message
|
|
2483
|
+
* stands alone; an empty list names the accepted family rather than rendering
|
|
2484
|
+
* `List<unknown>` (LOK-I3, LOK-I6).
|
|
2485
|
+
*/
|
|
2486
|
+
analyzeJsxLookAttribute(attribute) {
|
|
2487
|
+
const value = attribute.value;
|
|
2488
|
+
if (!value) {
|
|
2489
|
+
this.diagnostics.push(diagnostic("VEL5040", "JSX look requires an expression value", attribute.span));
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
if (typeof value === "string") {
|
|
2493
|
+
this.diagnostics.push(diagnostic("VEL5040", "JSX look requires an expression value such as look={cardLook}", attribute.span));
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
if (value.kind === "ExtensionExpression:web:look") {
|
|
2497
|
+
this.diagnostics.push(diagnostic("VEL5053", "An inline Look block is not supported; use look:property directives for simple overrides or extract a const Look for conditions and targets", attribute.span));
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
if (value.kind === "ListExpression" && value.elements.length === 0) {
|
|
2501
|
+
this.diagnostics.push(diagnostic("VEL5040", "JSX look accepts a Look, a Look?, or a list of Look values; an empty list composes nothing — remove the attribute", attribute.span));
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
const actual = this.inferExpression(value);
|
|
2505
|
+
if (!this.isLookInput(actual))
|
|
2506
|
+
this.diagnostics.push(diagnostic("VEL5040", `JSX look requires Look, Look?, or a list of Look values; received ${describeType(actual)}`, attribute.span));
|
|
2507
|
+
}
|
|
2508
|
+
analyzeComponentRef(expression, attribute, component) {
|
|
2509
|
+
const value = attribute.value;
|
|
2510
|
+
if (!value || typeof value === "string" || value.kind !== "IdentifierExpression"
|
|
2511
|
+
|| !this.lookup(value.name)?.mutable || this.writableStateName(value.name)) {
|
|
2512
|
+
this.diagnostics.push(diagnostic("VEL5057", "A component ref requires a mutable let binding", attribute.span));
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
const handle = webComponentHandle(component);
|
|
2516
|
+
if (!handle) {
|
|
2517
|
+
this.diagnostics.push(diagnostic("VEL5057", `Component '${expression.tag}' does not expose a Handle`, attribute.span));
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
const bindingType = this.lookup(value.name).type;
|
|
2521
|
+
if (bindingType.kind !== "any" && bindingType.kind !== "optional") {
|
|
2522
|
+
this.diagnostics.push(diagnostic("VEL5057", `A component ref requires ${describeType(handle)}? so cleanup can restore null`, attribute.span));
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const target = nonOptional(bindingType);
|
|
2526
|
+
if (target.kind !== "any" && !isAssignable(handle, target, this)) {
|
|
2527
|
+
this.diagnostics.push(diagnostic("VEL5057", `Component '${expression.tag}' exposes ${describeType(handle)}, but this ref stores ${describeType(target)}?`, attribute.span));
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
analyzeNativeJsxAttribute(expression, attribute) {
|
|
2531
|
+
const value = attribute.value;
|
|
2532
|
+
const eventName = attribute.name.startsWith("on:") ? attribute.name.slice(3).split(".")[0] ?? "" : "";
|
|
2533
|
+
const expectedEvent = eventName ? webEventType(eventName) : null;
|
|
2534
|
+
// GRM-A4: the declared handler type returns null. `() => {}` after a fat
|
|
2535
|
+
// arrow is an empty-record factory rather than an empty block, so a handler
|
|
2536
|
+
// position that accepted any result silently accepted that record.
|
|
2537
|
+
const eventHandlerType = expectedEvent ? { kind: "function", parameters: [expectedEvent], requiredParameters: 1, result: nullType } : null;
|
|
2538
|
+
// An event arrow that assigns a state binding from an event field is the
|
|
2539
|
+
// hand-rolled spelling of a two-way binding: it receives bind:value
|
|
2540
|
+
// guidance and skips ordinary handler inference so the guidance is not
|
|
2541
|
+
// buried under a cascade from the recovered assignment.
|
|
2542
|
+
const boundState = (eventName || /^on[A-Z]/u.test(attribute.name)) ? this.eventAssignedStateBinding(value) : null;
|
|
2543
|
+
if (boundState) {
|
|
2544
|
+
this.diagnostics.push(diagnostic("VEL5019", `Use 'bind:value={${boundState}}'; VelarScript binds input state with the bind: directive instead of assigning state from event fields`, attribute.span));
|
|
2545
|
+
}
|
|
2546
|
+
// A `look=` value is inferred inside its own check so an inline Look block
|
|
2547
|
+
// is reported once rather than analyzed as a snapshot as well.
|
|
2548
|
+
const inferred = typeof value === "string" ? stringType
|
|
2549
|
+
: boundState || attribute.name === "look" ? anyType
|
|
2550
|
+
: value ? this.inferExpression(value, eventHandlerType ?? unknownType) : boolType;
|
|
2551
|
+
if (attribute.name === "style") {
|
|
2552
|
+
this.diagnostics.push(diagnostic("VEL5041", "Raw JSX style is not supported; use style:property for a checked high-priority inline override, or prefer Look for ordinary visuals", attribute.span));
|
|
2553
|
+
}
|
|
2554
|
+
else if (attribute.name.startsWith("style:")) {
|
|
2555
|
+
this.analyzeInlineVisualAttribute(attribute, inferred, "style");
|
|
2556
|
+
}
|
|
2557
|
+
else if (attribute.name === "look") {
|
|
2558
|
+
this.analyzeJsxLookAttribute(attribute);
|
|
2559
|
+
}
|
|
2560
|
+
else if (attribute.name.startsWith("look:")) {
|
|
2561
|
+
this.analyzeInlineVisualAttribute(attribute, inferred, "look");
|
|
2562
|
+
}
|
|
2563
|
+
else if (attribute.name === "class") {
|
|
2564
|
+
if (!this.isClassInput(inferred))
|
|
2565
|
+
this.diagnostics.push(diagnostic("VEL5040", `JSX class requires string, string?, or a list of strings; received ${describeType(inferred)}`, attribute.span));
|
|
2566
|
+
}
|
|
2567
|
+
else if (attribute.name === "unsafe:html") {
|
|
2568
|
+
if (!isInvalidType(inferred) && inferred.kind !== "any" && !this.isOptionalString(inferred)) {
|
|
2569
|
+
this.diagnostics.push(diagnostic("VEL5047", `unsafe:html requires string or string?, received ${describeType(inferred)}`, attribute.span));
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
else if (attribute.name === "bind:value") {
|
|
2573
|
+
if (!this.isWritableBindTarget(value)) {
|
|
2574
|
+
this.diagnostics.push(diagnostic("VEL5019", bindTargetGuidance("bind:value"), attribute.span));
|
|
2575
|
+
}
|
|
2576
|
+
else {
|
|
2577
|
+
if (!["input", "textarea", "select"].includes(expression.tag))
|
|
2578
|
+
this.diagnostics.push(diagnostic("VEL5019", `bind:value is not valid on <${expression.tag}>`, attribute.span));
|
|
2579
|
+
const numeric = expression.tag === "input" && expression.attributes.some((item) => item.name === "type" && item.value === "number");
|
|
2580
|
+
this.requireAssignable(inferred, numeric ? numberType : stringType, attribute.span);
|
|
2581
|
+
if (!numeric && inferred.kind === "enum")
|
|
2582
|
+
this.enumValueBindings.set(attribute.span.start, inferred.name);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
else if (attribute.name === "bind:checked") {
|
|
2586
|
+
if (!this.isWritableBindTarget(value)) {
|
|
2587
|
+
this.diagnostics.push(diagnostic("VEL5019", bindTargetGuidance("bind:checked"), attribute.span));
|
|
2588
|
+
}
|
|
2589
|
+
else {
|
|
2590
|
+
if (expression.tag !== "input")
|
|
2591
|
+
this.diagnostics.push(diagnostic("VEL5019", `bind:checked is not valid on <${expression.tag}>`, attribute.span));
|
|
2592
|
+
this.requireAssignable(inferred, boolType, attribute.span);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
else if (attribute.name === "bind:group") {
|
|
2596
|
+
this.analyzeBindGroup(expression, attribute, value, inferred);
|
|
2597
|
+
}
|
|
2598
|
+
else if (attribute.name === "ref") {
|
|
2599
|
+
if (!value || typeof value === "string" || value.kind !== "IdentifierExpression" || !this.lookup(value.name)?.mutable) {
|
|
2600
|
+
this.diagnostics.push(diagnostic("VEL5020", "ref requires a mutable let binding", attribute.span));
|
|
2601
|
+
}
|
|
2602
|
+
else {
|
|
2603
|
+
const bindingType = this.lookup(value.name).type;
|
|
2604
|
+
const target = nonOptional(bindingType);
|
|
2605
|
+
const expected = expression.tag === "canvas" ? "CanvasElement" : expression.tag === "dialog" ? "DialogElement" : expression.tag === "textarea" ? "TextAreaElement" : ["input", "select"].includes(expression.tag) ? "InputElement" : "Element";
|
|
2606
|
+
const accepted = expected === "TextAreaElement" ? new Set(["TextAreaElement", "InputElement", "Element"]) : new Set([expected, "Element"]);
|
|
2607
|
+
if (bindingType.kind !== "any" && bindingType.kind !== "optional")
|
|
2608
|
+
this.diagnostics.push(diagnostic("VEL5024", `A <${expression.tag}> ref requires ${expected}? or a parent element type so cleanup can restore null`, attribute.span));
|
|
2609
|
+
else if (target.kind !== "any" && (target.kind !== "named" || !accepted.has(target.name)))
|
|
2610
|
+
this.diagnostics.push(diagnostic("VEL5024", `A <${expression.tag}> ref requires ${expected}? or a parent element type`, attribute.span));
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
else if (attribute.name === "bind") {
|
|
2614
|
+
this.diagnostics.push(diagnostic("VEL5019", "Use 'bind:value={name}'; the bind directive names the bound property, such as bind:value or bind:checked", attribute.span));
|
|
2615
|
+
}
|
|
2616
|
+
else if (/^on[A-Z]/u.test(attribute.name)) {
|
|
2617
|
+
if (attribute.name === "onEnter") {
|
|
2618
|
+
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));
|
|
2619
|
+
}
|
|
2620
|
+
else {
|
|
2621
|
+
const camel = attribute.name.slice(2);
|
|
2622
|
+
const event = camel === "DoubleClick" || camel === "DblClick" ? "dblclick" : camel.toLowerCase();
|
|
2623
|
+
this.diagnostics.push(diagnostic("VEL5025", nativeDomEventNames.has(event)
|
|
2624
|
+
? `Use 'on:${event}'; VelarScript event attributes use the on: directive`
|
|
2625
|
+
: "Use an 'on:event' directive with a native DOM event name, such as 'on:click' or 'on:keydown'", attribute.span));
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
else if (attribute.name.startsWith("on:")) {
|
|
2629
|
+
const [event, ...modifiers] = attribute.name.slice(3).split(".");
|
|
2630
|
+
const supported = new Set(["prevent", "stop", "once", "capture", "self"]);
|
|
2631
|
+
if (!event)
|
|
2632
|
+
this.diagnostics.push(diagnostic("VEL5025", "An event directive requires an event name", attribute.span));
|
|
2633
|
+
for (const modifier of modifiers)
|
|
2634
|
+
if (!supported.has(modifier))
|
|
2635
|
+
this.diagnostics.push(diagnostic("VEL5025", `Unknown event modifier '${modifier}'`, attribute.span));
|
|
2636
|
+
if (new Set(modifiers).size !== modifiers.length)
|
|
2637
|
+
this.diagnostics.push(diagnostic("VEL5025", "Event modifiers cannot be repeated", attribute.span));
|
|
2638
|
+
if (!isInvalidType(inferred) && inferred.kind !== "function" && inferred.kind !== "action" && inferred.kind !== "intrinsic" && inferred.kind !== "any") {
|
|
2639
|
+
this.diagnostics.push(diagnostic("VEL5021", `Event '${event}' requires a function`, attribute.span));
|
|
2640
|
+
}
|
|
2641
|
+
else if (inferred.kind === "function" || inferred.kind === "action" || inferred.kind === "intrinsic") {
|
|
2642
|
+
if (inferred.rest || inferred.parameters.length > 1)
|
|
2643
|
+
this.diagnostics.push(diagnostic("VEL5021", `Event '${event}' handlers accept zero parameters or one ${describeType(expectedEvent ?? { kind: "named", name: "Event" })} parameter`, attribute.span));
|
|
2644
|
+
else if (inferred.parameters.length === 1 && expectedEvent && !isAssignable(expectedEvent, inferred.parameters[0], this))
|
|
2645
|
+
this.diagnostics.push(diagnostic("VEL5021", `Event '${event}' provides ${describeType(expectedEvent)}, not ${describeType(inferred.parameters[0])}`, attribute.span));
|
|
2646
|
+
this.checkEventHandlerResult(event ?? "", value, inferred.result, attribute.span);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
else if (attribute.name.startsWith("class:")) {
|
|
2650
|
+
this.requireAssignable(inferred, boolType, attribute.span);
|
|
2651
|
+
}
|
|
2652
|
+
else if (attribute.name === "key" && !isInvalidType(inferred) && inferred.kind !== "string" && inferred.kind !== "number" && inferred.kind !== "enum" && inferred.kind !== "enumMember" && inferred.kind !== "any") {
|
|
2653
|
+
this.diagnostics.push(diagnostic("VEL5022", "A JSX key must be a string, string-backed enum, or number", attribute.span));
|
|
2654
|
+
}
|
|
2655
|
+
else if (!isInvalidType(inferred) && !this.isJsxAttributeValue(inferred)) {
|
|
2656
|
+
this.diagnostics.push(diagnostic("VEL5047", `Native JSX attributes require text, finite numbers, bool, enums, or null; received ${describeType(inferred)}`, attribute.span));
|
|
2657
|
+
}
|
|
2658
|
+
if (attribute.name.startsWith("on:click") && !["button", "a", "input", "select", "textarea", "summary"].includes(expression.tag)
|
|
2659
|
+
&& !expression.attributes.some((item) => item.name === "role"))
|
|
2660
|
+
this.diagnostics.push(diagnostic("VEL5023", `Clickable <${expression.tag}> requires an explicit role`, expression.span));
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* GRM-A4: an event handler runs for effect and returns null. The hole this
|
|
2664
|
+
* closes is `on:click={() => {}}`: after a fat arrow, braces build a record,
|
|
2665
|
+
* never a block, so the empty-record factory used to be accepted as a handler
|
|
2666
|
+
* and silently did nothing on every click.
|
|
2667
|
+
*/
|
|
2668
|
+
checkEventHandlerResult(event, value, result, attributeSpan) {
|
|
2669
|
+
const resolved = this.expandAliases(result);
|
|
2670
|
+
// An asynchronous handler stays legal whatever it resolves to: attaching an
|
|
2671
|
+
// action directly or through a wrapper is a decided spelling, and an action
|
|
2672
|
+
// already owns its pending state, its errors, and its result.
|
|
2673
|
+
if (resolved.kind === "promise")
|
|
2674
|
+
return;
|
|
2675
|
+
if (resolved.kind === "null" || resolved.kind === "any" || isInvalidType(resolved) || resolved.kind === "unknown")
|
|
2676
|
+
return;
|
|
2677
|
+
const emptyRecordBody = value && typeof value !== "string" && value.kind === "ArrowFunctionExpression"
|
|
2678
|
+
&& value.body.kind === "ObjectExpression" && value.body.properties.length === 0;
|
|
2679
|
+
this.diagnostics.push(diagnostic("VEL5021", emptyRecordBody
|
|
2680
|
+
? `Event '${event}' handlers return null, and '{}' after '=>' builds an empty record rather than an empty block; write '() => null' for a handler that does nothing, or name a 'def' that performs the work`
|
|
2681
|
+
: `Event '${event}' handlers return null; this handler returns ${describeType(result)} — the result is discarded, so call it inside a 'def' that returns null`, attributeSpan));
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* D47 rule 84(A): a bind target is a writable reactive location — a state
|
|
2685
|
+
* name, or a record-field / List-index / Map-key path rooted in one. A
|
|
2686
|
+
* computed accessor, a const, and a function result stay rejected: nothing
|
|
2687
|
+
* would receive the write.
|
|
2688
|
+
*/
|
|
2689
|
+
isWritableBindTarget(value) {
|
|
2690
|
+
if (!value || typeof value === "string")
|
|
2691
|
+
return false;
|
|
2692
|
+
if (value.kind === "IdentifierExpression")
|
|
2693
|
+
return this.writableStateName(value.name);
|
|
2694
|
+
return this.bindPathRoot(value) !== null;
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Walks a member/index path inward to its root state binding, checking every
|
|
2698
|
+
* segment is a writable location: a declared record field, a List element, or
|
|
2699
|
+
* a Map value. Returns the root state name, or null when the path is not a
|
|
2700
|
+
* writable reactive location.
|
|
2701
|
+
*/
|
|
2702
|
+
bindPathRoot(value) {
|
|
2703
|
+
const segments = [];
|
|
2704
|
+
let node = value;
|
|
2705
|
+
while (node.kind === "MemberExpression" || node.kind === "IndexExpression") {
|
|
2706
|
+
if (node.kind === "MemberExpression" && node.optional)
|
|
2707
|
+
return null;
|
|
2708
|
+
segments.push(node);
|
|
2709
|
+
node = node.object;
|
|
2710
|
+
}
|
|
2711
|
+
if (node.kind !== "IdentifierExpression" || !this.writableStateName(node.name))
|
|
2712
|
+
return null;
|
|
2713
|
+
let current = this.expandAliases(this.lookup(node.name).type);
|
|
2714
|
+
for (const segment of segments.reverse()) {
|
|
2715
|
+
if (segment.kind === "MemberExpression") {
|
|
2716
|
+
const fields = current.kind === "object" ? current.fields
|
|
2717
|
+
: current.kind === "named" ? this.fieldsOf(current.identity ?? current.name) : null;
|
|
2718
|
+
const field = fields?.get(segment.property);
|
|
2719
|
+
if (!field)
|
|
2720
|
+
return null;
|
|
2721
|
+
current = this.expandAliases(field);
|
|
2722
|
+
}
|
|
2723
|
+
else if (current.kind === "list")
|
|
2724
|
+
current = this.expandAliases(current.element);
|
|
2725
|
+
else if (current.kind === "map" || current.kind === "record")
|
|
2726
|
+
current = this.expandAliases(current.value);
|
|
2727
|
+
else
|
|
2728
|
+
return null;
|
|
2729
|
+
}
|
|
2730
|
+
return node.name;
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* D47 rule 84: `bind:group` binds a set of inputs that share one decision.
|
|
2734
|
+
* A radio group holds the selected input's `value`; a checkbox group holds the
|
|
2735
|
+
* checked values as a List<string>, so checking and unchecking are membership
|
|
2736
|
+
* changes.
|
|
2737
|
+
*/
|
|
2738
|
+
analyzeBindGroup(expression, attribute, value, inferred) {
|
|
2739
|
+
const inputType = expression.attributes.find((item) => item.name === "type")?.value;
|
|
2740
|
+
const kind = expression.tag === "input" && typeof inputType === "string" && (inputType === "radio" || inputType === "checkbox") ? inputType : null;
|
|
2741
|
+
if (!kind) {
|
|
2742
|
+
this.diagnostics.push(diagnostic("VEL5019", `bind:group binds a group of choices and requires <input type="radio"> or <input type="checkbox">; use bind:value for a single field and bind:checked for a single flag`, attribute.span));
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
if (!expression.attributes.some((item) => item.name === "value")) {
|
|
2746
|
+
this.diagnostics.push(diagnostic("VEL5019", `bind:group identifies each choice by its value attribute; add value="..." to this <input type="${kind}">`, attribute.span));
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (!this.isWritableBindTarget(value)) {
|
|
2750
|
+
this.diagnostics.push(diagnostic("VEL5019", bindTargetGuidance("bind:group"), attribute.span));
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const expected = kind === "radio" ? stringType : { kind: "list", element: stringType };
|
|
2754
|
+
if (kind === "radio" && inferred.kind === "enum") {
|
|
2755
|
+
this.enumValueBindings.set(attribute.span.start, inferred.name);
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
this.requireAssignable(inferred, expected, attribute.span);
|
|
2759
|
+
}
|
|
2760
|
+
// Matches 'event => stateName = event.field' (any member depth) where the
|
|
2761
|
+
// assignment target is a writable state binding, and returns that binding's
|
|
2762
|
+
// name; anything else returns null.
|
|
2763
|
+
eventAssignedStateBinding(value) {
|
|
2764
|
+
if (!value || typeof value === "string")
|
|
2765
|
+
return null;
|
|
2766
|
+
if (value.kind !== "ArrowFunctionExpression" || value.asynchronous || value.parameters.length !== 1)
|
|
2767
|
+
return null;
|
|
2768
|
+
const body = value.body;
|
|
2769
|
+
if (body.kind !== "AssignmentExpression" || body.target.kind !== "IdentifierExpression")
|
|
2770
|
+
return null;
|
|
2771
|
+
const state = body.target.name;
|
|
2772
|
+
if (!this.writableStateName(state))
|
|
2773
|
+
return null;
|
|
2774
|
+
let source = body.value;
|
|
2775
|
+
while (source.kind === "MemberExpression")
|
|
2776
|
+
source = source.object;
|
|
2777
|
+
return source.kind === "IdentifierExpression" && source.name === value.parameters[0].name && body.value.kind === "MemberExpression"
|
|
2778
|
+
? state
|
|
2779
|
+
: null;
|
|
2780
|
+
}
|
|
2781
|
+
isLookInput(type) {
|
|
2782
|
+
if (type.kind === "any" || type.kind === "null")
|
|
2783
|
+
return true;
|
|
2784
|
+
if (type.kind === "named")
|
|
2785
|
+
return type.name === "Look";
|
|
2786
|
+
if (type.kind === "optional")
|
|
2787
|
+
return this.isLookInput(type.inner);
|
|
2788
|
+
if (type.kind === "list")
|
|
2789
|
+
return this.isLookInput(type.element);
|
|
2790
|
+
if (type.kind === "union")
|
|
2791
|
+
return type.members.every((member) => this.isLookInput(member));
|
|
2792
|
+
return false;
|
|
2793
|
+
}
|
|
2794
|
+
isClassInput(type) {
|
|
2795
|
+
if (type.kind === "any" || type.kind === "null" || type.kind === "string")
|
|
2796
|
+
return true;
|
|
2797
|
+
if (type.kind === "optional")
|
|
2798
|
+
return this.isClassInput(type.inner);
|
|
2799
|
+
if (type.kind === "list")
|
|
2800
|
+
return this.isClassInput(type.element);
|
|
2801
|
+
if (type.kind === "union")
|
|
2802
|
+
return type.members.every((member) => this.isClassInput(member));
|
|
2803
|
+
return false;
|
|
2804
|
+
}
|
|
2805
|
+
isJsxRenderable(type) {
|
|
2806
|
+
const expanded = this.expandAliases(type);
|
|
2807
|
+
if (expanded.kind === "any" || expanded.kind === "null" || expanded.kind === "string" || expanded.kind === "number"
|
|
2808
|
+
|| expanded.kind === "bool" || expanded.kind === "enum" || expanded.kind === "enumMember" || isWebNodeType(expanded))
|
|
2809
|
+
return true;
|
|
2810
|
+
if (expanded.kind === "named")
|
|
2811
|
+
return textualWebPrimitiveNames.has(expanded.name);
|
|
2812
|
+
if (expanded.kind === "optional")
|
|
2813
|
+
return this.isJsxRenderable(expanded.inner);
|
|
2814
|
+
if (expanded.kind === "list")
|
|
2815
|
+
return this.isJsxRenderable(expanded.element);
|
|
2816
|
+
if (expanded.kind === "union")
|
|
2817
|
+
return expanded.members.every((member) => this.isJsxRenderable(member));
|
|
2818
|
+
return false;
|
|
2819
|
+
}
|
|
2820
|
+
isJsxAttributeValue(type) {
|
|
2821
|
+
const expanded = this.expandAliases(type);
|
|
2822
|
+
if (expanded.kind === "any" || expanded.kind === "null" || expanded.kind === "string" || expanded.kind === "number"
|
|
2823
|
+
|| expanded.kind === "bool" || expanded.kind === "enum" || expanded.kind === "enumMember")
|
|
2824
|
+
return true;
|
|
2825
|
+
if (expanded.kind === "named")
|
|
2826
|
+
return textualWebPrimitiveNames.has(expanded.name);
|
|
2827
|
+
if (expanded.kind === "optional")
|
|
2828
|
+
return this.isJsxAttributeValue(expanded.inner);
|
|
2829
|
+
if (expanded.kind === "union")
|
|
2830
|
+
return expanded.members.every((member) => this.isJsxAttributeValue(member));
|
|
2831
|
+
return false;
|
|
2832
|
+
}
|
|
2833
|
+
isOptionalString(type) {
|
|
2834
|
+
const expanded = this.expandAliases(type);
|
|
2835
|
+
if (expanded.kind === "string" || expanded.kind === "null")
|
|
2836
|
+
return true;
|
|
2837
|
+
if (expanded.kind === "optional")
|
|
2838
|
+
return this.isOptionalString(expanded.inner);
|
|
2839
|
+
if (expanded.kind === "union")
|
|
2840
|
+
return expanded.members.every((member) => this.isOptionalString(member));
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2843
|
+
checkWebRouteComponent(type, sourceSpan, subject) {
|
|
2844
|
+
if (isInvalidType(type))
|
|
2845
|
+
return;
|
|
2846
|
+
if (!isWebComponentType(type)) {
|
|
2847
|
+
if (type.kind !== "any")
|
|
2848
|
+
this.typeError(`${subject} requires a component, received ${describeType(type)}`, sourceSpan);
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const unsupported = [...type.requiredProperties].filter((name) => name !== "route");
|
|
2852
|
+
if (unsupported.length > 0)
|
|
2853
|
+
this.typeError(`${subject} component cannot require props other than route: ${unsupported.join(", ")}`, sourceSpan);
|
|
2854
|
+
const routeProp = type.properties.get("route");
|
|
2855
|
+
if (routeProp && !isAssignable({ kind: "named", name: "RouteContext", identity: routeContextIdentity }, routeProp, this))
|
|
2856
|
+
this.typeError(`${subject} component's route prop must accept RouteContext, received ${describeType(routeProp)}`, sourceSpan);
|
|
2857
|
+
}
|
|
2858
|
+
analyzeInlineVisualAttribute(attribute, actual, directive) {
|
|
2859
|
+
const property = attribute.name.slice(`${directive}:`.length);
|
|
2860
|
+
if (!property) {
|
|
2861
|
+
this.diagnostics.push(diagnostic("VEL5038", `A ${directive}: directive requires a camelCase Look property name`, attribute.span));
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
if (attribute.value === null) {
|
|
2865
|
+
this.diagnostics.push(diagnostic("VEL5040", `JSX ${directive}:${property} requires a string or expression value`, attribute.span));
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const expression = typeof attribute.value === "string"
|
|
2869
|
+
? { kind: "LiteralExpression", value: attribute.value, raw: JSON.stringify(attribute.value), span: attribute.span }
|
|
2870
|
+
: attribute.value;
|
|
2871
|
+
if (!this.analyzeLookValue(property, expression, attribute.span, directive))
|
|
2872
|
+
return;
|
|
2873
|
+
const expected = LOOK_PROPERTY_TYPES.get(property) ?? stringType;
|
|
2874
|
+
if (mentionsLookUnitType(expected) && lookLiteralZero(expression))
|
|
2875
|
+
return;
|
|
2876
|
+
if (this.reportLookNumberWithoutUnit(property, actual, expected, expression.span))
|
|
2877
|
+
return;
|
|
2878
|
+
if (expected.kind !== "unknown")
|
|
2879
|
+
this.requireAssignable(actual, optionalOf(expected), expression.span);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
function webTypeFields(name) {
|
|
2883
|
+
const functionType = (parameterNames, parameters, result) => ({ kind: "function", parameterNames, parameters, requiredParameters: parameters.length, result });
|
|
2884
|
+
const eventFields = () => new Map([
|
|
2885
|
+
["type", stringType], ["defaultPrevented", boolType], ["preventDefault", functionType([], [], nullType)], ["stopPropagation", functionType([], [], nullType)],
|
|
2886
|
+
]);
|
|
2887
|
+
if (name === "Event")
|
|
2888
|
+
return eventFields();
|
|
2889
|
+
if (name === "KeyboardEvent")
|
|
2890
|
+
return new Map([...eventFields(), ["key", stringType], ["code", stringType], ["repeat", boolType], ["altKey", boolType], ["ctrlKey", boolType], ["metaKey", boolType], ["shiftKey", boolType]]);
|
|
2891
|
+
if (name === "PointerEvent")
|
|
2892
|
+
return new Map([...eventFields(), ["pointerId", numberType], ["pointerType", stringType], ["pressure", numberType], ["button", numberType], ["buttons", numberType], ["clientX", numberType], ["clientY", numberType], ["movementX", numberType], ["movementY", numberType], ["altKey", boolType], ["ctrlKey", boolType], ["metaKey", boolType], ["shiftKey", boolType]]);
|
|
2893
|
+
if (name === "InputEvent")
|
|
2894
|
+
return new Map([...eventFields(), ["data", optionalOf(stringType)], ["inputType", stringType], ["isComposing", boolType]]);
|
|
2895
|
+
if (name === "CompositionEvent")
|
|
2896
|
+
return new Map([...eventFields(), ["data", stringType]]);
|
|
2897
|
+
if (name === "ClipboardEvent")
|
|
2898
|
+
return eventFields();
|
|
2899
|
+
if (name === "Blob")
|
|
2900
|
+
return new Map();
|
|
2901
|
+
if (name === "File")
|
|
2902
|
+
return new Map([["name", stringType], ["size", numberType], ["type", stringType], ["modified", numberType]]);
|
|
2903
|
+
if (name === "Element" || name === "InputElement" || name === "TextAreaElement" || name === "CanvasElement" || name === "DialogElement") {
|
|
2904
|
+
const fields = new Map([["focus", functionType([], [], nullType)], ["remove", functionType([], [], nullType)]]);
|
|
2905
|
+
if (name === "InputElement" || name === "TextAreaElement")
|
|
2906
|
+
fields.set("value", stringType);
|
|
2907
|
+
if (name === "InputElement")
|
|
2908
|
+
fields.set("checked", boolType);
|
|
2909
|
+
if (name === "CanvasElement") {
|
|
2910
|
+
fields.set("width", numberType);
|
|
2911
|
+
fields.set("height", numberType);
|
|
2912
|
+
fields.set("getContext", functionType(["kind"], [stringType], unknownType));
|
|
2913
|
+
}
|
|
2914
|
+
return fields;
|
|
2915
|
+
}
|
|
2916
|
+
return null;
|
|
2917
|
+
}
|
|
2918
|
+
function webEventType(name) {
|
|
2919
|
+
if (name === "keydown" || name === "keyup" || name === "keypress")
|
|
2920
|
+
return { kind: "named", name: "KeyboardEvent" };
|
|
2921
|
+
if (["click", "pointerdown", "pointerup", "pointermove", "pointercancel", "pointerover", "pointerout", "pointerenter", "pointerleave"].includes(name))
|
|
2922
|
+
return { kind: "named", name: "PointerEvent" };
|
|
2923
|
+
if (name === "beforeinput" || name === "input")
|
|
2924
|
+
return { kind: "named", name: "InputEvent" };
|
|
2925
|
+
if (name === "compositionstart" || name === "compositionupdate" || name === "compositionend")
|
|
2926
|
+
return { kind: "named", name: "CompositionEvent" };
|
|
2927
|
+
if (name === "copy" || name === "cut" || name === "paste")
|
|
2928
|
+
return { kind: "named", name: "ClipboardEvent" };
|
|
2929
|
+
return { kind: "named", name: "Event" };
|
|
2930
|
+
}
|
|
2931
|
+
//# sourceMappingURL=analyzer.js.map
|