@velarscript/web 0.28.0 → 0.29.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, mutatingCollectionMethods, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
2
+ import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
3
3
  import { BROWSER_TEST_MODULE, BROWSER_TEST_SOURCE_SUFFIX, browserTestImportGuidance } from "./browser-test.js";
4
4
  import { cssTokens } from "./css-tokens.js";
5
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";
@@ -10,6 +10,7 @@ import { dynamicChildLeaves, JSX_SCALAR_TEXT_HINT } from "./emitter.js";
10
10
  import { isWebCustomElementName, WEB_ARIA_ATTRIBUTES, WEB_ARIA_ENUMERATED_VALUES, WEB_ARIA_ROLES, WEB_ARIA_ROLE_SYNONYMS, WEB_BOOL_PRESENCE_HTML_ATTRIBUTES, WEB_HTML_ELEMENTS, WEB_MISSPELLED_ATTRIBUTES, WEB_NATIVE_ELEMENTS, } from "./elements.js";
11
11
  import { isWebExpression, isWebJsx, isWebKeyframes, isWebLook, isWebStatement, isWebUnit, } from "./ast.js";
12
12
  import { isWebComponentConstructor, isWebComponentType, isWebComputedExport, isWebNodeType, normalizeWebComponentType, webComponentConstructor, webComponentHandle, webComponentIntrinsic, webComponentName, webNodeType, WEB_EVENT_TYPE_NAMES, WEB_OWNED_TYPE_NAMES, } from "./types.js";
13
+ import { collectionMutators, collectReactiveWriters, reactivePathOf, reactivePathRoot, reactiveStepsBelow, reactiveWriteCandidate, statementBindsName, topLevelCall, writerWritesPath, } from "./analysis/watch-cycles.js";
13
14
  // The canonical nominal identity of the Web RouteContext record. Route checks
14
15
  // probe with this identity so they succeed in modules that use route() without
15
16
  // importing RouteContext by name.
@@ -1181,169 +1182,6 @@ function watchSubjectPath(expression) {
1181
1182
  return false;
1182
1183
  }
1183
1184
  }
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
- }
1347
1185
  /** The escapes a text literal carries back into source (`scanStringEscape`). */
1348
1186
  const WATCH_SUBJECT_TEXT_ESCAPES = {
1349
1187
  "\\": "\\\\",
@@ -2034,11 +1872,19 @@ export class VelarWebAnalyzer extends Analyzer {
2034
1872
  * `builtinTypeNameDeclarationMessage` in packages/compiler/src/analyzer.ts,
2035
1873
  * reported as VEL3007. The rosters differ; the wording is meant to read
2036
1874
  * alike, so a change to either sentence belongs in both.
1875
+ *
1876
+ * `Duration` is on both rosters — Core owns it as a primitive and
1877
+ * `velar/look` republishes it — so a Web module used to report it twice. This
1878
+ * refusal is the more specific of the two, because it names the surface the
1879
+ * author is writing against, so it marks the name refused and Core's stays
1880
+ * unsaid. The mark is Core's own hook, which is what lets this pass take
1881
+ * precedence without either side learning the other's roster.
2037
1882
  */
2038
1883
  rejectWebOwnedTypeNames(program) {
2039
1884
  const reject = (name, errorSpan, noun) => {
2040
1885
  if (!WEB_OWNED_TYPE_NAMES.has(name))
2041
1886
  return;
1887
+ this.markTypeNameRefused(name);
2042
1888
  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));
2043
1889
  };
2044
1890
  for (const statement of program.body) {
@@ -2659,12 +2505,8 @@ export class VelarWebAnalyzer extends Analyzer {
2659
2505
  const root = reactivePathRoot(subject);
2660
2506
  if (root === null)
2661
2507
  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;
2508
+ const place = reactivePathOf(subject);
2509
+ const writes = (steps, method) => this.watchSubjectWrite(watched, steps, method);
2668
2510
  // A resource publishes `value`, `loading`, `ready` and `error`, and
2669
2511
  // `reload` is the one member of the five that is not one of them. Asking it
2670
2512
  // that way keeps `analyzeResourceDeclaration`'s field map the only roster:
@@ -2677,14 +2519,9 @@ export class VelarWebAnalyzer extends Analyzer {
2677
2519
  for (const statement of body) {
2678
2520
  if (statementBindsName(statement, root))
2679
2521
  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));
2522
+ const selfWrite = place === null ? null : this.watchSelfWrite(subject, place, statement, writes);
2523
+ if (selfWrite !== null) {
2524
+ this.diagnostics.push(diagnostic("VEL5077", selfWrite, statement.span));
2688
2525
  return;
2689
2526
  }
2690
2527
  const call = topLevelCall(statement);
@@ -2696,15 +2533,104 @@ export class VelarWebAnalyzer extends Analyzer {
2696
2533
  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
2534
  return;
2698
2535
  }
2699
- if (path === null || call.callee.kind !== "IdentifierExpression")
2536
+ if (place === null || call.callee.kind !== "IdentifierExpression")
2700
2537
  continue;
2701
2538
  const writer = this.reactiveWriters.get(call.callee.name) ?? null;
2702
- if (writer === null || !writerWritesPath(writer, path, root, mutating))
2539
+ const written = writer === null ? null : writerWritesPath(writer, place, writes);
2540
+ if (written === null)
2703
2541
  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));
2542
+ // D114 0.28.0 H-D1's other half, in the message family F1 gave VEL5077:
2543
+ // a writer that reaches a *part* of the subject names the part it wrote
2544
+ // and the subject it belongs to, because those are two different places
2545
+ // and the author has to find the one the helper touches.
2546
+ const reached = written.text === place.text
2547
+ ? `'${place.text}' — the reactive value this watch is on`
2548
+ : `'${written.text}', a part of its subject '${place.text}'`;
2549
+ this.diagnostics.push(diagnostic("VEL5079", `This watch starts '${call.callee.name}', which writes ${reached} — so each completed run re-triggers the watch; make the write conditional, or watch the input '${call.callee.name}' reads`, call.span));
2705
2550
  return;
2706
2551
  }
2707
2552
  }
2553
+ /**
2554
+ * D114 0.28.0 H-D1: the VEL5077 message one plain body statement earns, or
2555
+ * null when it earns none.
2556
+ *
2557
+ * §15 says a watch fires on a *deep* change of its subject, so `watch form:
2558
+ * form.name = …` and `watch items: items[0].done = …` are the same ring
2559
+ * `items.append(…)` already is — decided at the top of the body, with no
2560
+ * condition to end it — and were silent until the runtime's 100-round cap
2561
+ * stopped them. The rule is therefore stated on the path rather than on the
2562
+ * spelling: a write whose place is the subject, or any place below it, in an
2563
+ * assignment, a compound assignment, or a mutating call.
2564
+ *
2565
+ * Every existing exclusion stands, because each is answered somewhere else: a
2566
+ * conditional or nested write is not a plain body statement, a rebinding
2567
+ * stops the scan in `rejectWatchCycle`, and a sibling path (`watch form.name:`
2568
+ * writing `form.email`) or a different root fails the step comparison here.
2569
+ */
2570
+ watchSelfWrite(subject, place, statement, writes) {
2571
+ const write = reactiveWriteCandidate(statement);
2572
+ if (write === null)
2573
+ return null;
2574
+ const steps = reactiveStepsBelow(place, write.place);
2575
+ if (steps === null || !writes(steps, write.method))
2576
+ return null;
2577
+ // A derived value is offered only where it could be declared. A field
2578
+ // or an element has no `computed` spelling of its own, so naming one
2579
+ // would hand the author a line that does not compile.
2580
+ const derived = subject.kind === "IdentifierExpression"
2581
+ ? `declare 'computed ${place.text} = ...' instead`
2582
+ : "write this value where it is produced instead";
2583
+ const head = steps.length === 0
2584
+ ? `This watch writes its own subject '${place.text}'`
2585
+ : `This watch writes '${write.place.text}', a part of its subject '${place.text}',`;
2586
+ return `${head} at the top of its body, so every run re-triggers it and the runtime stops the loop after 100`
2587
+ + ` rounds; write the condition that ends it, or watch the input this value follows and ${derived}`;
2588
+ }
2589
+ /**
2590
+ * D114 0.28.0 H-D1: the type of the place `steps` below the watched subject,
2591
+ * or null when the walk cannot reach one.
2592
+ *
2593
+ * Only the steps the reactive graph publishes as part of the subject are
2594
+ * walked — a record field and a collection element — because those are the
2595
+ * writes a watch on the containing value is woken by. A step that leaves them
2596
+ * (a class instance, a capability handle, a field the record does not declare)
2597
+ * is not provably part of the subject, and a refusal that must be right every
2598
+ * time answers no there rather than guessing.
2599
+ */
2600
+ /**
2601
+ * Whether a write `steps` below the watched subject, made the given way, is a
2602
+ * write of the subject — the one definition both the body scan (VEL5077) and
2603
+ * the one-hop writer scan (VEL5079) read. An assignment to a place the walk
2604
+ * can reach is a write; a call is one only when the type at that depth is a
2605
+ * collection and the call is on its own mutating roster.
2606
+ */
2607
+ watchSubjectWrite(watched, steps, method) {
2608
+ const written = this.reactivePlaceType(watched, steps);
2609
+ if (written === null)
2610
+ return false;
2611
+ if (method === null)
2612
+ return true;
2613
+ const mutating = collectionMutators(nonOptional(this.expandAliases(written)));
2614
+ return mutating !== null && mutating.has(method);
2615
+ }
2616
+ reactivePlaceType(subject, steps) {
2617
+ let current = subject;
2618
+ for (const step of steps) {
2619
+ const owner = nonOptional(this.expandAliases(current));
2620
+ const next = step.kind === "field"
2621
+ ? (owner.kind === "object" ? owner.fields.get(step.name) ?? null
2622
+ : owner.kind === "record" ? owner.value
2623
+ : owner.kind === "named" ? this.fieldsOf(owner.identity ?? owner.name)?.get(step.name) ?? null
2624
+ : null)
2625
+ : (owner.kind === "list" || owner.kind === "set" ? owner.element
2626
+ : owner.kind === "map" || owner.kind === "record" ? owner.value
2627
+ : null);
2628
+ if (next === null)
2629
+ return null;
2630
+ current = next;
2631
+ }
2632
+ return current;
2633
+ }
2708
2634
  /**
2709
2635
  * D89 A4: records `list = list.map(item => {…})`, React's immutable update,
2710
2636
  * where the callback builds a new record rather than changing a field.