@velarscript/web 0.27.3 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/analyzer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mechanicalEdits, mechanicalFix, 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";
2
+ import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, mutatingCollectionMethods, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
3
3
  import { BROWSER_TEST_MODULE, BROWSER_TEST_SOURCE_SUFFIX, browserTestImportGuidance } from "./browser-test.js";
4
4
  import { cssTokens } from "./css-tokens.js";
5
5
  import { LOOK_ABSENT_MEDIA_SUBJECTS, LOOK_ARITHMETIC_HINT, LOOK_ANIMATION_DIRECTIONS, LOOK_ANIMATION_EASINGS, LOOK_ANIMATION_FILLS, LOOK_BORDER_STYLE_NAMES, LOOK_BUILDER_NUMERIC_RANGES, LOOK_BUILDER_SIGNATURES, LOOK_BUILDERS, LOOK_EXCLUDED_PROPERTIES, LOOK_HOOKS, LOOK_CSS_WIDE_KEYWORDS, LOOK_COLOR_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_SHARED_METRIC_KEYWORDS, LOOK_TARGETS, LOOK_TOKEN_NAME_RULE, LOOK_TOKEN_NO_FALLBACK_GUIDANCE, LOOK_UNIT_TYPES, LOOK_UNITLESS_PROPERTIES, isLookTokenName, isLookVarReference, lookOwnKeywords, lookShorthandOverlap, lookShorthandParts, lookTokenReference, lookVarReferenceName, nearestLookName, } from "./look.js";
@@ -72,6 +72,7 @@ const lookColor = { kind: "named", name: "Color" };
72
72
  const lookImage = { kind: "named", name: "Image" };
73
73
  const lookBorder = { kind: "named", name: "Border" };
74
74
  const lookShadow = { kind: "named", name: "Shadow" };
75
+ const lookFilter = { kind: "named", name: "Filter" };
75
76
  const lookDuration = { kind: "named", name: "Duration" };
76
77
  const lookAngle = { kind: "named", name: "Angle" };
77
78
  const lookTrackList = { kind: "named", name: "TrackList" };
@@ -101,7 +102,7 @@ const lookPropertyType = (kind) => {
101
102
  case "shadow": return { kind: "union", members: [lookShadow, stringType] };
102
103
  case "track": return { kind: "union", members: [lookTrackList, stringType] };
103
104
  case "transition": return { kind: "union", members: [lookTransition, stringType] };
104
- case "filter":
105
+ case "filter": return { kind: "union", members: [lookFilter, stringType] };
105
106
  case "keyword":
106
107
  case "text":
107
108
  case "transform":
@@ -428,6 +429,147 @@ function lookShorthandStringGuidance(name, value) {
428
429
  }
429
430
  return null;
430
431
  }
432
+ const lookSimpleFilterBuilders = new Map([
433
+ ["blur", { builder: "blur", argument: "length" }],
434
+ ["brightness", { builder: "brightness", argument: "number" }],
435
+ ["contrast", { builder: "contrast", argument: "number" }],
436
+ ["grayscale", { builder: "grayscale", argument: "number" }],
437
+ ["hue-rotate", { builder: "hueRotate", argument: "angle" }],
438
+ ["invert", { builder: "invert", argument: "number" }],
439
+ ["opacity", { builder: "filterOpacity", argument: "number" }],
440
+ ["saturate", { builder: "saturate", argument: "number" }],
441
+ ["sepia", { builder: "sepia", argument: "number" }],
442
+ ]);
443
+ function cssFunctionValues(text) {
444
+ const values = [];
445
+ let cursor = 0;
446
+ while (cursor < text.length) {
447
+ while (cursor < text.length && /\s/u.test(text[cursor]))
448
+ cursor += 1;
449
+ if (cursor === text.length)
450
+ break;
451
+ const matched = /^([a-z-]+)\(/iu.exec(text.slice(cursor));
452
+ if (!matched)
453
+ return null;
454
+ const name = matched[1].toLowerCase();
455
+ const start = cursor + matched[0].length;
456
+ let depth = 1;
457
+ let quote = null;
458
+ let escaped = false;
459
+ cursor = start;
460
+ while (cursor < text.length && depth > 0) {
461
+ const character = text[cursor];
462
+ if (escaped)
463
+ escaped = false;
464
+ else if (quote && character === "\\")
465
+ escaped = true;
466
+ else if (quote && character === quote)
467
+ quote = null;
468
+ else if (!quote && (character === "\"" || character === "'"))
469
+ quote = character;
470
+ else if (!quote && character === "(")
471
+ depth += 1;
472
+ else if (!quote && character === ")")
473
+ depth -= 1;
474
+ cursor += 1;
475
+ }
476
+ if (depth !== 0 || quote)
477
+ return null;
478
+ values.push({ name, arguments: text.slice(start, cursor - 1).trim() });
479
+ if (cursor < text.length && !/\s/u.test(text[cursor]))
480
+ return null;
481
+ }
482
+ return values.length > 0 ? values : null;
483
+ }
484
+ function topLevelWords(text) {
485
+ const words = [];
486
+ let start = 0;
487
+ let depth = 0;
488
+ for (let cursor = 0; cursor <= text.length; cursor += 1) {
489
+ const character = text[cursor];
490
+ if (character === "(")
491
+ depth += 1;
492
+ else if (character === ")") {
493
+ depth -= 1;
494
+ if (depth < 0)
495
+ return null;
496
+ }
497
+ if ((character === undefined || /\s/u.test(character)) && depth === 0) {
498
+ const word = text.slice(start, cursor).trim();
499
+ if (word)
500
+ words.push(word);
501
+ start = cursor + 1;
502
+ }
503
+ }
504
+ return depth === 0 ? words : null;
505
+ }
506
+ function filterLength(text) {
507
+ return /^(?:0|\+?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|vw|vh|vmin|vmax))$/u.test(text)
508
+ ? lookBuilderToken(text)
509
+ : null;
510
+ }
511
+ function filterNumericArgumentsHold(builder, values) {
512
+ const ranges = LOOK_BUILDER_NUMERIC_RANGES.get(builder);
513
+ return values.every((value, index) => {
514
+ const range = ranges?.[index];
515
+ const numeric = Number(value);
516
+ return Number.isFinite(numeric) && (!range || (numeric >= range[1] && numeric <= range[2]));
517
+ });
518
+ }
519
+ function filterColor(text) {
520
+ if (/^#[0-9a-f]{3,8}$/iu.test(text) || /^[a-z]+$/iu.test(text)) {
521
+ return { call: `color(${JSON.stringify(text)})`, builders: ["color"] };
522
+ }
523
+ const functional = /^(rgb|rgba)\((.*)\)$/iu.exec(text);
524
+ if (!functional)
525
+ return null;
526
+ const channels = functional[2].split(",").map((channel) => channel.trim());
527
+ const expected = functional[1].toLowerCase() === "rgb" ? 3 : 4;
528
+ if (channels.length !== expected || channels.some((channel) => !/^\+?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(channel)))
529
+ return null;
530
+ const builder = functional[1].toLowerCase();
531
+ if (!filterNumericArgumentsHold(builder, channels))
532
+ return null;
533
+ return { call: `${builder}(${channels.join(", ")})`, builders: [builder] };
534
+ }
535
+ function filterFunctionRewrite(value) {
536
+ const simple = lookSimpleFilterBuilders.get(value.name);
537
+ if (simple) {
538
+ const argument = simple.argument === "length"
539
+ ? filterLength(value.arguments)
540
+ : simple.argument === "angle"
541
+ ? (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|turn)$/u.test(value.arguments) ? value.arguments : null)
542
+ : (/^\+?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(value.arguments)
543
+ && filterNumericArgumentsHold(simple.builder, [value.arguments]) ? value.arguments : null);
544
+ return argument === null ? null : { call: `${simple.builder}(${argument})`, builders: [simple.builder] };
545
+ }
546
+ if (value.name !== "drop-shadow")
547
+ return null;
548
+ const words = topLevelWords(value.arguments);
549
+ if (!words || words.length !== 4)
550
+ return null;
551
+ const lengths = words.slice(0, 3).map(filterLength);
552
+ const color = filterColor(words[3]);
553
+ if (lengths.some((length) => length === null) || !color)
554
+ return null;
555
+ return {
556
+ call: `dropShadow(${lengths.join(", ")}, ${color.call})`,
557
+ builders: ["dropShadow", ...color.builders],
558
+ };
559
+ }
560
+ function lookFilterRewrite(text) {
561
+ const functions = cssFunctionValues(text.trim());
562
+ if (!functions)
563
+ return null;
564
+ const rewritten = functions.map(filterFunctionRewrite);
565
+ if (rewritten.some((item) => item === null))
566
+ return null;
567
+ const calls = rewritten;
568
+ const builders = [...new Set(calls.flatMap((item) => item.builders))];
569
+ if (calls.length === 1)
570
+ return { call: calls[0].call, builders };
571
+ return { call: `filters(${calls.map((item) => item.call).join(", ")})`, builders: ["filters", ...builders] };
572
+ }
431
573
  /**
432
574
  * D104 rule 2 — what a refusal says when two entries in one scope write the
433
575
  * same CSS declaration. Written once because two positions raise it: a Look
@@ -1039,6 +1181,169 @@ function watchSubjectPath(expression) {
1039
1181
  return false;
1040
1182
  }
1041
1183
  }
1184
+ /**
1185
+ * D114 W: a reactive place written as one comparable key, so "the write and the
1186
+ * subject name the same place" is one string equality.
1187
+ *
1188
+ * It is deliberately narrower than `renderWatchSubject`, which reconstructs any
1189
+ * expression for a message. A key has to *decide*, so only the parts that name
1190
+ * the same place on two evaluations are allowed into one: names, fields, and an
1191
+ * index that is either a literal or another such path. `items[next()]` renders
1192
+ * perfectly well and answers a different element every call, so it has no key
1193
+ * and the shapes below stay silent on it — which is the right answer for a
1194
+ * refusal that has to be right every time.
1195
+ */
1196
+ function reactiveWritePath(expression) {
1197
+ switch (expression.kind) {
1198
+ case "IdentifierExpression":
1199
+ return expression.name;
1200
+ case "MemberExpression": {
1201
+ if (expression.optional)
1202
+ return null;
1203
+ const object = reactiveWritePath(expression.object);
1204
+ return object === null ? null : `${object}.${expression.property}`;
1205
+ }
1206
+ case "IndexExpression": {
1207
+ if (expression.optional)
1208
+ return null;
1209
+ const object = reactiveWritePath(expression.object);
1210
+ if (object === null)
1211
+ return null;
1212
+ const index = expression.index.kind === "LiteralExpression"
1213
+ ? (typeof expression.index.value === "string" ? JSON.stringify(expression.index.value) : expression.index.raw)
1214
+ : reactiveWritePath(expression.index);
1215
+ return index === null ? null : `${object}[${index}]`;
1216
+ }
1217
+ default:
1218
+ return null;
1219
+ }
1220
+ }
1221
+ /** The root name a reactive path starts from, which is the binding it resolves through. */
1222
+ function reactivePathRoot(expression) {
1223
+ switch (expression.kind) {
1224
+ case "IdentifierExpression":
1225
+ return expression.name;
1226
+ case "MemberExpression":
1227
+ case "IndexExpression":
1228
+ return reactivePathRoot(expression.object);
1229
+ default:
1230
+ return null;
1231
+ }
1232
+ }
1233
+ function bindingPatternBinds(pattern, name) {
1234
+ switch (pattern.kind) {
1235
+ case "NameBindingPattern":
1236
+ return pattern.name === name;
1237
+ case "ObjectBindingPattern":
1238
+ return pattern.rest?.name === name || pattern.entries.some((entry) => bindingPatternBinds(entry.pattern, name));
1239
+ case "ListBindingPattern":
1240
+ return pattern.rest?.name === name
1241
+ || pattern.elements.some((element) => element !== null && bindingPatternBinds(element, name));
1242
+ default:
1243
+ return false;
1244
+ }
1245
+ }
1246
+ /**
1247
+ * D114 W: whether a body statement introduces its own binding of `name`. From
1248
+ * that statement on, the spelling names something else, and a write through it
1249
+ * is not a write of the watched place. The scan stops there rather than
1250
+ * guessing which of the two a later line meant.
1251
+ */
1252
+ function statementBindsName(statement, name) {
1253
+ switch (statement.kind) {
1254
+ case "VariableDeclaration":
1255
+ return bindingPatternBinds(statement.pattern, name);
1256
+ case "UsingDeclaration":
1257
+ case "FunctionDeclaration":
1258
+ case "ClassDeclaration":
1259
+ return statement.name === name;
1260
+ default:
1261
+ return false;
1262
+ }
1263
+ }
1264
+ /**
1265
+ * D114 W: the call a body statement makes when the statement is nothing but
1266
+ * that call. `detach` is included because it is how a synchronous watch body
1267
+ * starts asynchronous work — the tour and four charter fences spell the reload
1268
+ * that way — so a refusal that only saw the bare call would miss the shape it
1269
+ * exists for. Everything else (a call inside an `if`, an argument, an assigned
1270
+ * result) is not a plain top-level call and is not offered here.
1271
+ */
1272
+ function topLevelCall(statement) {
1273
+ const expression = statement.kind === "ExpressionStatement" ? statement.expression
1274
+ : statement.kind === "DetachStatement" ? statement.expression
1275
+ : null;
1276
+ return expression !== null && expression.kind === "CallExpression" ? expression : null;
1277
+ }
1278
+ /**
1279
+ * D114 W: whether one plain body statement writes the reactive place `path`.
1280
+ * An assignment or a compound assignment to it is one; so is a call of a
1281
+ * mutating collection method on it, because a watch on a collection fires on
1282
+ * its deep mutation and `mutating` is the compiler's own roster of the calls
1283
+ * that mutate.
1284
+ */
1285
+ function reactiveWriteOf(statement, path, mutating) {
1286
+ if (statement.kind === "AssignmentStatement")
1287
+ return reactiveWritePath(statement.target) === path;
1288
+ if (mutating === null)
1289
+ return false;
1290
+ const call = statement.kind === "ExpressionStatement" && statement.expression.kind === "CallExpression"
1291
+ ? statement.expression
1292
+ : null;
1293
+ if (call === null || call.callee.kind !== "MemberExpression" || call.callee.optional)
1294
+ return false;
1295
+ return mutating.has(call.callee.property) && reactiveWritePath(call.callee.object) === path;
1296
+ }
1297
+ /**
1298
+ * D114 W A2(b): whether an `action` or `async def` writes `path` at its own top
1299
+ * level, unconditionally. One hop: what the callee itself calls is not
1300
+ * followed. A parameter of the callee's own that is spelled like the path's
1301
+ * root, or a binding it declares before the write, means the write is not of
1302
+ * the watched place and the answer is no.
1303
+ */
1304
+ function writerWritesPath(writer, path, root, mutating) {
1305
+ if (writer.parameters.includes(root))
1306
+ return false;
1307
+ for (const statement of writer.body) {
1308
+ if (statementBindsName(statement, root))
1309
+ return false;
1310
+ if (reactiveWriteOf(statement, path, mutating))
1311
+ return true;
1312
+ }
1313
+ return false;
1314
+ }
1315
+ function collectReactiveWriters(program) {
1316
+ const writers = new Map();
1317
+ const claim = (name, declaration) => {
1318
+ writers.set(name, writers.has(name) ? null : declaration);
1319
+ };
1320
+ const record = (statements) => {
1321
+ for (const statement of statements) {
1322
+ if (statement.kind === "FunctionDeclaration") {
1323
+ claim(statement.name, statement.asynchronous
1324
+ ? { spelling: "async def", parameters: statement.parameters.map((parameter) => parameter.name), body: statement.body }
1325
+ : null);
1326
+ record(statement.body);
1327
+ continue;
1328
+ }
1329
+ if (!isWebStatement(statement))
1330
+ continue;
1331
+ if (statement.kind === "ExtensionStatement:web:action") {
1332
+ claim(statement.name, {
1333
+ spelling: "action",
1334
+ parameters: statement.parameters.map((parameter) => parameter.name),
1335
+ body: statement.body,
1336
+ });
1337
+ record(statement.body);
1338
+ continue;
1339
+ }
1340
+ if (statement.kind === "ExtensionStatement:web:component")
1341
+ record(statement.body);
1342
+ }
1343
+ };
1344
+ record(program.body);
1345
+ return writers;
1346
+ }
1042
1347
  /** The escapes a text literal carries back into source (`scanStringEscape`). */
1043
1348
  const WATCH_SUBJECT_TEXT_ESCAPES = {
1044
1349
  "\\": "\\\\",
@@ -1640,6 +1945,15 @@ export class VelarWebAnalyzer extends Analyzer {
1640
1945
  * name resolves to even where a narrowed copy answers the lookup.
1641
1946
  */
1642
1947
  computedBindingSpans = new Set();
1948
+ /**
1949
+ * D114 W: the declaration spans of every `resource` in scope, kept the way
1950
+ * `computedBindingSpans` keeps derived values — the question is asked of the
1951
+ * binding a name resolves to, so a local shadow of a resource's name is not
1952
+ * one.
1953
+ */
1954
+ resourceBindingSpans = new Set();
1955
+ /** D114 W A2(b): this module's `action` and `async def` bodies by name. */
1956
+ reactiveWriters = new Map();
1643
1957
  /** Local names bound to an imported `export computed`, from the Web interface. */
1644
1958
  importedComputedNames;
1645
1959
  /** The resolved spans of those imports, so a local shadow of the name is not one. */
@@ -1678,6 +1992,7 @@ export class VelarWebAnalyzer extends Analyzer {
1678
1992
  this.keyedListSources.clear();
1679
1993
  this.keyedListRebuilds.length = 0;
1680
1994
  this.moduleFunctions = collectModuleFunctions(program);
1995
+ this.reactiveWriters = collectReactiveWriters(program);
1681
1996
  super.analyze(program);
1682
1997
  this.reportStaticJsxKeys();
1683
1998
  this.reportRetiredComputedFunction();
@@ -1714,6 +2029,11 @@ export class VelarWebAnalyzer extends Analyzer {
1714
2029
  * to `WEB_OWNED_TYPE_NAMES` extends this protection with it. The last time
1715
2030
  * this family was repaired by listing names instead of deriving them, the
1716
2031
  * list drifted; D57 rule 135 is the same repair on the Core roster.
2032
+ *
2033
+ * Core now says the same sentence about its own built-in type names —
2034
+ * `builtinTypeNameDeclarationMessage` in packages/compiler/src/analyzer.ts,
2035
+ * reported as VEL3007. The rosters differ; the wording is meant to read
2036
+ * alike, so a change to either sentence belongs in both.
1717
2037
  */
1718
2038
  rejectWebOwnedTypeNames(program) {
1719
2039
  const reject = (name, errorSpan, noun) => {
@@ -1850,7 +2170,9 @@ export class VelarWebAnalyzer extends Analyzer {
1850
2170
  // the body only runs on a later change, so its reads are deferred
1851
2171
  // for the module-initialization-cycle classification.
1852
2172
  const watched = this.inferExpression(statement.expression);
1853
- this.rejectFrozenWatchSubject(statement.expression, watched, statement.currentName, statement.previousName);
2173
+ if (this.rejectFrozenWatchSubject(statement.expression, watched, statement.currentName, statement.previousName)) {
2174
+ this.rejectWatchCycle(statement.expression, watched, statement.body);
2175
+ }
1854
2176
  this.enterScope();
1855
2177
  if (statement.currentName)
1856
2178
  this.declareBinding(statement.currentName, false, watched, statement.span);
@@ -1985,6 +2307,15 @@ export class VelarWebAnalyzer extends Analyzer {
1985
2307
  const binding = this.lookup(name);
1986
2308
  return binding !== null && this.computedBindingSpans.has(spanIdentity(binding.span));
1987
2309
  }
2310
+ /**
2311
+ * True when `name` resolves to a `resource` declaration. Asked of the binding
2312
+ * rather than of the spelling, for the reason `isComputedBinding` is: a local
2313
+ * `state` may shadow a resource's name, and the shadow is not a resource.
2314
+ */
2315
+ isResourceBinding(name) {
2316
+ const binding = this.lookup(name);
2317
+ return binding !== null && this.resourceBindingSpans.has(spanIdentity(binding.span));
2318
+ }
1988
2319
  isImportedComputedBinding(name) {
1989
2320
  const binding = this.lookup(name);
1990
2321
  return binding !== null && this.importedComputedSpans.has(spanIdentity(binding.span));
@@ -2273,14 +2604,14 @@ export class VelarWebAnalyzer extends Analyzer {
2273
2604
  const name = expression.kind === "IdentifierExpression" ? expression.name : null;
2274
2605
  if (name !== null && this.reactiveBindingKind(name) === null && this.zeroArgumentReader(watched)) {
2275
2606
  this.diagnostics.push(diagnostic("VEL5064", `'${name}' is the reader itself, so watching it watches a value that never changes; declare the derived value — 'computed name = ${name}()' — then 'watch name:'`, expression.span));
2276
- return;
2607
+ return false;
2277
2608
  }
2278
2609
  if (this.frozenWatchSubject(expression)) {
2279
2610
  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));
2280
- return;
2611
+ return false;
2281
2612
  }
2282
2613
  if (watchSubjectPath(expression))
2283
- return;
2614
+ return true;
2284
2615
  // D69's own shape, `watch total()`, is a called `computed`, and VEL5063 has
2285
2616
  // already named it with the one-character edit that makes this subject
2286
2617
  // legal. Stacking the shape rule on top would report one mistake twice and
@@ -2288,7 +2619,7 @@ export class VelarWebAnalyzer extends Analyzer {
2288
2619
  // VEL5063 in its turn. The same reason the frozen rule is asked first.
2289
2620
  if (this.diagnostics.some((item) => item.code === "VEL5063"
2290
2621
  && item.span.start === expression.span.start && item.span.end === expression.span.end))
2291
- return;
2622
+ return false;
2292
2623
  const derived = currentName ?? "value";
2293
2624
  const watchLine = currentName === null
2294
2625
  ? `watch ${derived}:`
@@ -2297,6 +2628,82 @@ export class VelarWebAnalyzer extends Analyzer {
2297
2628
  this.diagnostics.push(diagnostic("VEL5071", rendered === null
2298
2629
  ? `A watch subject names what to watch, not what to compute. Declare the value — 'computed ${derived} = ...' — then '${watchLine}'`
2299
2630
  : `A watch subject names what to watch, not what to compute: '${rendered}' computes a value. Declare it — 'computed ${derived} = ${rendered}' — then '${watchLine}'`, expression.span));
2631
+ return false;
2632
+ }
2633
+ /**
2634
+ * D114 W: the three watch shapes a compile can prove re-trigger the watch
2635
+ * itself. D90 R21 removed the analysis of *who writes what* across calls, and
2636
+ * nothing here brings it back: two watches writing one state are still an
2637
+ * ordinary program, a write reached through an ordinary helper is still
2638
+ * silent, and a write under `if`, `match`, a loop, `try`, a nested `def` or an
2639
+ * arrow is still the author's converging correction to make.
2640
+ *
2641
+ * What is refused is only what is decided at the top of the body, with no
2642
+ * condition to end it:
2643
+ *
2644
+ * - **B** the body writes the watched place itself (`watch count: count =
2645
+ * count + 1`), assignment, compound assignment, or a mutating collection
2646
+ * call on the watched collection;
2647
+ * - **A2(a)** the subject is a `resource` field and the body reloads that
2648
+ * same resource — a reload writes exactly those fields, so every completed
2649
+ * load re-triggers the watch;
2650
+ * - **A2(b)** the body starts an `action` or an `async def` of this module
2651
+ * whose own top level writes the watched place. One hop, one module, no
2652
+ * condition on either end; anything further is the runtime budget's.
2653
+ *
2654
+ * One diagnostic per watch, at the first statement that earns it. A watch with
2655
+ * two of these has two mistakes, and the second is read after the first is
2656
+ * fixed, exactly as two errors on one line are.
2657
+ */
2658
+ rejectWatchCycle(subject, watched, body) {
2659
+ const root = reactivePathRoot(subject);
2660
+ if (root === null)
2661
+ return;
2662
+ const path = reactiveWritePath(subject);
2663
+ const collection = nonOptional(this.expandAliases(watched));
2664
+ const mutating = collection.kind === "list" || collection.kind === "map"
2665
+ || collection.kind === "set" || collection.kind === "record"
2666
+ ? mutatingCollectionMethods(collection.kind)
2667
+ : null;
2668
+ // A resource publishes `value`, `loading`, `ready` and `error`, and
2669
+ // `reload` is the one member of the five that is not one of them. Asking it
2670
+ // that way keeps `analyzeResourceDeclaration`'s field map the only roster:
2671
+ // a field added there is a field this recognises, with nothing to update.
2672
+ const resource = subject.kind === "MemberExpression" && !subject.optional
2673
+ && subject.object.kind === "IdentifierExpression" && subject.property !== "reload"
2674
+ && this.isResourceBinding(subject.object.name)
2675
+ ? subject.object.name
2676
+ : null;
2677
+ for (const statement of body) {
2678
+ if (statementBindsName(statement, root))
2679
+ return;
2680
+ if (path !== null && reactiveWriteOf(statement, path, mutating)) {
2681
+ // A derived value is offered only where it could be declared. A field
2682
+ // or an element has no `computed` spelling of its own, so naming one
2683
+ // would hand the author a line that does not compile.
2684
+ const derived = subject.kind === "IdentifierExpression"
2685
+ ? `declare 'computed ${path} = ...' instead`
2686
+ : "write this value where it is produced instead";
2687
+ this.diagnostics.push(diagnostic("VEL5077", `This watch writes its own subject '${path}' at the top of its body, so every run re-triggers it and the runtime stops the loop after 100 rounds; write the condition that ends it, or watch the input this value follows and ${derived}`, statement.span));
2688
+ return;
2689
+ }
2690
+ const call = topLevelCall(statement);
2691
+ if (call === null)
2692
+ continue;
2693
+ if (resource !== null && call.callee.kind === "MemberExpression" && !call.callee.optional
2694
+ && call.callee.property === "reload" && call.callee.object.kind === "IdentifierExpression"
2695
+ && call.callee.object.name === resource) {
2696
+ this.diagnostics.push(diagnostic("VEL5078", `This watch reloads '${resource}' — the resource it watches — so every completed load re-triggers it; watch the input the load reads instead, as 'watch userId:' with 'detach ${resource}.reload()' in its body`, call.span));
2697
+ return;
2698
+ }
2699
+ if (path === null || call.callee.kind !== "IdentifierExpression")
2700
+ continue;
2701
+ const writer = this.reactiveWriters.get(call.callee.name) ?? null;
2702
+ if (writer === null || !writerWritesPath(writer, path, root, mutating))
2703
+ continue;
2704
+ this.diagnostics.push(diagnostic("VEL5079", `This watch starts '${call.callee.name}', which writes '${path}' — the reactive value this watch is on — so each completed run re-triggers the watch; make the write conditional, or watch the input '${call.callee.name}' reads`, call.span));
2705
+ return;
2706
+ }
2300
2707
  }
2301
2708
  /**
2302
2709
  * D89 A4: records `list = list.map(item => {…})`, React's immutable update,
@@ -2695,6 +3102,7 @@ export class VelarWebAnalyzer extends Analyzer {
2695
3102
  ["reload", { kind: "function", parameters: [], requiredParameters: 0, result: { kind: "promise", value: nullType } }],
2696
3103
  ]);
2697
3104
  this.declareBinding(statement.name, false, { kind: "object", fields }, statement.span);
3105
+ this.resourceBindingSpans.add(spanIdentity(statement.span));
2698
3106
  }
2699
3107
  actionType(statement) {
2700
3108
  const declaredResult = this.resolvedAsyncResult(this.inferredFunctionResult(statement));
@@ -2783,7 +3191,9 @@ export class VelarWebAnalyzer extends Analyzer {
2783
3191
  this.flowFrameDepth += 1;
2784
3192
  this.synchronousReactiveDepth += 1;
2785
3193
  const watched = this.inferExpression(item.expression);
2786
- this.rejectFrozenWatchSubject(item.expression, watched, item.currentName, item.previousName);
3194
+ if (this.rejectFrozenWatchSubject(item.expression, watched, item.currentName, item.previousName)) {
3195
+ this.rejectWatchCycle(item.expression, watched, item.body);
3196
+ }
2787
3197
  this.enterScope();
2788
3198
  if (item.currentName)
2789
3199
  this.declareBinding(item.currentName, false, watched, item.span);
@@ -3090,10 +3500,15 @@ export class VelarWebAnalyzer extends Analyzer {
3090
3500
  if (range && literal !== null && (literal < range[1] || literal > range[2])) {
3091
3501
  this.diagnostics.push(diagnostic("VEL5042", `${range[0]} must be from ${range[1]} through ${range[2]}; ${builder} received ${literal}`, argument.span));
3092
3502
  }
3503
+ const nonNegativeBlur = (builder === "blur" && position === 0) || (builder === "dropShadow" && position === 2);
3504
+ if (nonNegativeBlur && folded?.kind === "unit" && folded.value < 0) {
3505
+ this.diagnostics.push(diagnostic("VEL5042", `${builder} blur cannot be negative`, argument.span));
3506
+ }
3093
3507
  // LOK-D3, builder half: a unitless number in a length position is dead
3094
3508
  // CSS exactly as it is on a property. Zero is the one unitless length.
3095
3509
  if (LOOK_LENGTH_BUILDERS.has(builder) && literal !== null && literal !== 0
3096
- && !(builder === "border" && position !== 0) && !(builder === "shadow" && position === 5)) {
3510
+ && !(builder === "border" && position !== 0) && !(builder === "shadow" && position === 5)
3511
+ && !(builder === "dropShadow" && position === 3)) {
3097
3512
  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));
3098
3513
  }
3099
3514
  if (builder === "border" && position === 2 && argument.kind === "LiteralExpression" && typeof argument.value === "string"
@@ -3118,6 +3533,9 @@ export class VelarWebAnalyzer extends Analyzer {
3118
3533
  if (builder === "tracks" && expression.arguments.length > 1024) {
3119
3534
  this.diagnostics.push(diagnostic("VEL5042", "tracks cannot contain more than 1024 values", expression.span));
3120
3535
  }
3536
+ if (builder === "filters" && expression.arguments.length > 64) {
3537
+ this.diagnostics.push(diagnostic("VEL5042", "filters cannot compose more than 64 values", expression.span));
3538
+ }
3121
3539
  }
3122
3540
  /**
3123
3541
  * D103 rules 1 and 5 — what a checked token reference has to be at the site
@@ -3196,15 +3614,7 @@ export class VelarWebAnalyzer extends Analyzer {
3196
3614
  }
3197
3615
  /** The one edit that gives this module a `token` import, in the shape D103's migration needs. */
3198
3616
  lookTokenImportEdit() {
3199
- const site = this.lookImport ?? { declaration: null, insertAt: 0, leadingBlankLine: true };
3200
- const specifiers = [...(site.declaration?.specifiers ?? []), { imported: "token", local: "token" }];
3201
- const line = `import {${[...specifiers]
3202
- .sort((left, right) => byCodeUnit(left.imported, right.imported))
3203
- .map((specifier) => specifier.imported === specifier.local ? specifier.imported : `${specifier.imported} as ${specifier.local}`)
3204
- .join(", ")}} from "velar/look"`;
3205
- if (site.declaration)
3206
- return { span: site.declaration.span, text: line };
3207
- return { span: { start: site.insertAt, end: site.insertAt }, text: site.leadingBlankLine ? `${line}\n\n` : `\n${line}` };
3617
+ return this.lookBuilderImportEdit(["token"]);
3208
3618
  }
3209
3619
  checkAnimateBuilderCall(expression) {
3210
3620
  const argument = (name, position) => {
@@ -3401,6 +3811,7 @@ export class VelarWebAnalyzer extends Analyzer {
3401
3811
  return false;
3402
3812
  }
3403
3813
  const site = { property: name, entrySpan, directive };
3814
+ this.adviseLookFilterSpelling(name, value, site);
3404
3815
  this.adviseLookTokenSpelling(name, value, site);
3405
3816
  if (!this.validateLookStringVocabulary(name, value, undefined, site))
3406
3817
  return false;
@@ -3411,6 +3822,63 @@ export class VelarWebAnalyzer extends Analyzer {
3411
3822
  return value.kind === "CallExpression" && value.callee.kind === "IdentifierExpression"
3412
3823
  && this.lookBuilderNames.get(value.callee.name) === "token";
3413
3824
  }
3825
+ /**
3826
+ * A complete CSS filter function whose arguments fit the checked Look
3827
+ * builders has one equivalent source spelling. The parser deliberately
3828
+ * stops at the first function or argument grammar it cannot prove, leaving
3829
+ * arbitrary CSS and externally defined filter functions as ordinary text.
3830
+ */
3831
+ adviseLookFilterSpelling(name, value, site) {
3832
+ if (LOOK_PROPERTY_VALUE_KINDS.get(name) !== "filter")
3833
+ return;
3834
+ if (value.kind !== "LiteralExpression" || typeof value.value !== "string")
3835
+ return;
3836
+ const rewrite = lookFilterRewrite(value.value);
3837
+ if (!rewrite)
3838
+ return;
3839
+ const localized = this.localizeLookBuilderCall(rewrite);
3840
+ this.advise("A16", `Look property '${name}' accepts CSS filter text, but this complete filter list has the checked equivalent ${localized.call}`, value.span, mechanicalEdits(this.lookBuilderRewrite(value, localized.call, site, localized.missingImports), `Use ${localized.call}`));
3841
+ }
3842
+ /** Uses an existing alias when present and lists only builders the fix must import. */
3843
+ localizeLookBuilderCall(rewrite) {
3844
+ let call = rewrite.call;
3845
+ const missingImports = [];
3846
+ for (const builder of rewrite.builders) {
3847
+ const imported = [...this.lookBuilderNames].find(([, candidate]) => candidate === builder);
3848
+ const local = imported?.[0] ?? builder;
3849
+ if (!imported)
3850
+ missingImports.push(builder);
3851
+ call = call.replace(new RegExp(`\\b${builder}(?=\\()`, "gu"), local);
3852
+ }
3853
+ return { call, missingImports };
3854
+ }
3855
+ /** Rewrites either a Look entry expression or the synthetic value of a JSX Look directive. */
3856
+ lookBuilderRewrite(value, call, site, missingImports) {
3857
+ const wholeAttribute = site.directive !== null
3858
+ && value.span.start === site.entrySpan.start && value.span.end === site.entrySpan.end;
3859
+ const edit = {
3860
+ span: value.span,
3861
+ text: wholeAttribute ? `${site.directive}:${site.property}={${call}}` : call,
3862
+ };
3863
+ return missingImports.length === 0 ? [edit] : [this.lookBuilderImportEdit(missingImports), edit];
3864
+ }
3865
+ /** Adds missing builders to the module's one velar/look import in stable order. */
3866
+ lookBuilderImportEdit(builders) {
3867
+ const site = this.lookImport ?? { declaration: null, insertAt: 0, leadingBlankLine: true };
3868
+ const existing = site.declaration?.specifiers ?? [];
3869
+ const imported = new Set(existing.map((specifier) => specifier.imported));
3870
+ const specifiers = [
3871
+ ...existing,
3872
+ ...builders.filter((builder) => !imported.has(builder)).map((builder) => ({ imported: builder, local: builder })),
3873
+ ];
3874
+ const line = `import {${specifiers
3875
+ .sort((left, right) => byCodeUnit(left.imported, right.imported))
3876
+ .map((specifier) => specifier.imported === specifier.local ? specifier.imported : `${specifier.imported} as ${specifier.local}`)
3877
+ .join(", ")}} from "velar/look"`;
3878
+ if (site.declaration)
3879
+ return { span: site.declaration.span, text: line };
3880
+ return { span: { start: site.insertAt, end: site.insertAt }, text: site.leadingBlankLine ? `${line}\n\n` : `\n${line}` };
3881
+ }
3414
3882
  /** How this module spells a call to the token builder, honouring an aliased import. */
3415
3883
  lookTokenCallText(referenced) {
3416
3884
  const local = [...this.lookBuilderNames].find(([, builder]) => builder === "token")?.[0] ?? null;