@askrjs/cli 0.0.15 → 0.0.16

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.
@@ -11,7 +11,6 @@ const DEFAULT_ANALYZE_EXCLUDES = [
11
11
  "**/build/**",
12
12
  "**/coverage/**",
13
13
  "**/.git/**",
14
- "**/.askr/**",
15
14
  "**/.next/**",
16
15
  "**/.output/**",
17
16
  "**/.turbo/**",
@@ -238,273 +237,6 @@ const RENDER_SCOPED_CONCEPTS = /* @__PURE__ */ new Set([
238
237
  const POSITIONAL_DATA_CONCEPTS = /* @__PURE__ */ new Set(["createQuery", "createMutation"]);
239
238
  const ASKR_MODULE_PATTERN = /^@askrjs\/askr(?:\/|$)/;
240
239
  //#endregion
241
- //#region src/analyze/call-graph.ts
242
- const CALL_GRAPH_CACHE = /* @__PURE__ */ new WeakMap();
243
- function isLocalFunction(node) {
244
- return Boolean(node && ts.isFunctionLike(node) && "body" in node && node.body);
245
- }
246
- function containingLocalFunction(node) {
247
- for (let current = node.parent; current; current = current.parent) if (isLocalFunction(current)) return current;
248
- return null;
249
- }
250
- function declarationFunction(declaration) {
251
- if (!declaration) return null;
252
- if (isLocalFunction(declaration)) return declaration;
253
- if (ts.isVariableDeclaration(declaration) && declaration.initializer && isLocalFunction(declaration.initializer)) return declaration.initializer;
254
- if (ts.isPropertyAssignment(declaration) && isLocalFunction(declaration.initializer)) return declaration.initializer;
255
- return null;
256
- }
257
- function symbolForExpression(checker, expression) {
258
- const location = ts.isPropertyAccessExpression(expression) ? expression.name : expression;
259
- let symbol = checker.getSymbolAtLocation(location) ?? checker.getSymbolAtLocation(expression);
260
- if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol);
261
- return symbol;
262
- }
263
- function resolveLocalFunction(checker, sourceFiles, expression) {
264
- const symbol = symbolForExpression(checker, expression);
265
- const declarations = [symbol?.valueDeclaration, ...symbol?.declarations ?? []];
266
- for (const declaration of declarations) {
267
- const fn = declarationFunction(declaration);
268
- if (fn && sourceFiles.has(fn.getSourceFile())) return fn;
269
- }
270
- return null;
271
- }
272
- function localNamedFunction(sourceFile, name) {
273
- for (const statement of sourceFile.statements) {
274
- if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return isLocalFunction(statement) ? statement : null;
275
- if (!ts.isVariableStatement(statement)) continue;
276
- for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer && isLocalFunction(declaration.initializer)) return declaration.initializer;
277
- }
278
- return null;
279
- }
280
- function localCallGraph(context) {
281
- const cached = CALL_GRAPH_CACHE.get(context.program);
282
- if (cached) return cached;
283
- const sourceFiles = new Set(context.sourceFiles);
284
- const sourceByPath = new Map(context.sourceFiles.map((sourceFile) => [path.resolve(sourceFile.fileName), sourceFile]));
285
- const moduleCache = /* @__PURE__ */ new Map();
286
- const resolveModule = (containingFile, specifier) => {
287
- if (!specifier.startsWith(".")) return null;
288
- const cacheKey = `${containingFile.fileName}\0${specifier}`;
289
- if (moduleCache.has(cacheKey)) return moduleCache.get(cacheKey) ?? null;
290
- const base = path.resolve(path.dirname(containingFile.fileName), specifier);
291
- const withoutJsExtension = base.replace(/\.(?:c|m)?js$/, "");
292
- const candidates = [
293
- base,
294
- `${base}.ts`,
295
- `${base}.tsx`,
296
- `${base}.mts`,
297
- `${base}.cts`,
298
- `${base}.js`,
299
- `${base}.jsx`,
300
- `${withoutJsExtension}.ts`,
301
- `${withoutJsExtension}.tsx`,
302
- path.join(base, "index.ts"),
303
- path.join(base, "index.tsx"),
304
- path.join(base, "index.js")
305
- ];
306
- for (const candidate of candidates) {
307
- const resolved = sourceByPath.get(candidate);
308
- if (resolved) {
309
- moduleCache.set(cacheKey, resolved);
310
- return resolved;
311
- }
312
- }
313
- moduleCache.set(cacheKey, null);
314
- return null;
315
- };
316
- const exportCache = /* @__PURE__ */ new Map();
317
- const exportedFunction = (sourceFile, name, seen = /* @__PURE__ */ new Set()) => {
318
- const key = `${sourceFile.fileName}\0${name}`;
319
- if (exportCache.has(key)) return exportCache.get(key) ?? null;
320
- if (seen.has(key)) return null;
321
- seen.add(key);
322
- const direct = localNamedFunction(sourceFile, name);
323
- if (direct) {
324
- exportCache.set(key, direct);
325
- return direct;
326
- }
327
- exportCache.set(key, null);
328
- for (const statement of sourceFile.statements) {
329
- if (name === "default" && ts.isFunctionDeclaration(statement) && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) && isLocalFunction(statement)) {
330
- exportCache.set(key, statement);
331
- return statement;
332
- }
333
- if (!ts.isExportDeclaration(statement)) continue;
334
- const target = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) ? resolveModule(sourceFile, statement.moduleSpecifier.text) : sourceFile;
335
- if (!target) continue;
336
- if (!statement.exportClause) {
337
- const resolved = exportedFunction(target, name, seen);
338
- if (resolved) {
339
- exportCache.set(key, resolved);
340
- return resolved;
341
- }
342
- continue;
343
- }
344
- if (!ts.isNamedExports(statement.exportClause)) continue;
345
- const element = statement.exportClause.elements.find((candidate) => candidate.name.text === name);
346
- if (!element) continue;
347
- const imported = element.propertyName?.text ?? element.name.text;
348
- const resolved = target === sourceFile ? localNamedFunction(sourceFile, imported) : exportedFunction(target, imported, seen);
349
- exportCache.set(key, resolved);
350
- return resolved;
351
- }
352
- return null;
353
- };
354
- const importBindings = /* @__PURE__ */ new Map();
355
- for (const sourceFile of context.sourceFiles) {
356
- const named = /* @__PURE__ */ new Map();
357
- const namespaces = /* @__PURE__ */ new Map();
358
- for (const statement of sourceFile.statements) {
359
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
360
- const target = resolveModule(sourceFile, statement.moduleSpecifier.text);
361
- if (!target) continue;
362
- const clause = statement.importClause;
363
- if (clause?.name) named.set(clause.name.text, {
364
- source: target,
365
- imported: "default"
366
- });
367
- const bindings = clause?.namedBindings;
368
- if (bindings && ts.isNamedImports(bindings)) for (const element of bindings.elements) named.set(element.name.text, {
369
- source: target,
370
- imported: element.propertyName?.text ?? element.name.text
371
- });
372
- else if (bindings && ts.isNamespaceImport(bindings)) namespaces.set(bindings.name.text, target);
373
- }
374
- importBindings.set(sourceFile, {
375
- named,
376
- namespaces
377
- });
378
- }
379
- const resolveImportedFunction = (expression) => {
380
- const imports = importBindings.get(expression.getSourceFile());
381
- if (!imports) return null;
382
- if (ts.isIdentifier(expression)) {
383
- const imported = imports.named.get(expression.text);
384
- return imported ? exportedFunction(imported.source, imported.imported) : null;
385
- }
386
- if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
387
- const source = imports.namespaces.get(expression.expression.text);
388
- return source ? exportedFunction(source, expression.name.text) : null;
389
- }
390
- return null;
391
- };
392
- const targetByExpression = /* @__PURE__ */ new WeakMap();
393
- const resolveFunctionExpression = (expression) => {
394
- if (targetByExpression.has(expression)) return targetByExpression.get(expression) ?? null;
395
- const target = resolveLocalFunction(context.checker, sourceFiles, expression) ?? resolveImportedFunction(expression);
396
- targetByExpression.set(expression, target);
397
- return target;
398
- };
399
- const functions = /* @__PURE__ */ new Set();
400
- const callNodes = [];
401
- const constructionNodes = [];
402
- const walk = (node) => {
403
- if (isLocalFunction(node)) functions.add(node);
404
- if (ts.isCallExpression(node)) callNodes.push(node);
405
- if (ts.isNewExpression(node)) constructionNodes.push(node);
406
- ts.forEachChild(node, walk);
407
- };
408
- for (const sourceFile of context.sourceFiles) walk(sourceFile);
409
- const targetByCall = /* @__PURE__ */ new Map();
410
- const graph = {
411
- calls: callNodes.map((node) => {
412
- const target = resolveFunctionExpression(node.expression);
413
- targetByCall.set(node, target);
414
- return {
415
- node,
416
- owner: containingLocalFunction(node),
417
- target
418
- };
419
- }),
420
- constructions: constructionNodes.map((node) => ({
421
- node,
422
- owner: containingLocalFunction(node)
423
- })),
424
- functions,
425
- functionForCall(call) {
426
- return targetByCall.get(call) ?? null;
427
- },
428
- functionForExpression(expression) {
429
- return resolveFunctionExpression(expression);
430
- }
431
- };
432
- CALL_GRAPH_CACHE.set(context.program, graph);
433
- return graph;
434
- }
435
- function addAll(target, source) {
436
- let changed = false;
437
- for (const value of source) {
438
- if (target.has(value)) continue;
439
- target.add(value);
440
- changed = true;
441
- }
442
- return changed;
443
- }
444
- function summarizeTransitiveCalls(context, classifyDirectOperation, callIsUnstable) {
445
- const graph = localCallGraph(context);
446
- const mutable = /* @__PURE__ */ new Map();
447
- const reverseEdges = /* @__PURE__ */ new Map();
448
- const ensure = (fn) => {
449
- const existing = mutable.get(fn);
450
- if (existing) return existing;
451
- const created = {
452
- values: /* @__PURE__ */ new Set(),
453
- unstableValues: /* @__PURE__ */ new Set()
454
- };
455
- mutable.set(fn, created);
456
- return created;
457
- };
458
- for (const fn of graph.functions) ensure(fn);
459
- for (const site of graph.calls) {
460
- if (!site.owner) continue;
461
- const direct = classifyDirectOperation(site.node);
462
- if (direct) {
463
- const summary = ensure(site.owner);
464
- summary.values.add(direct.value);
465
- if (direct.unstable) summary.unstableValues.add(direct.value);
466
- }
467
- if (!site.target) continue;
468
- const incoming = reverseEdges.get(site.target) ?? [];
469
- incoming.push({
470
- owner: site.owner,
471
- unstable: callIsUnstable(site.node, site.owner)
472
- });
473
- reverseEdges.set(site.target, incoming);
474
- }
475
- for (const site of graph.constructions) {
476
- if (!site.owner) continue;
477
- const direct = classifyDirectOperation(site.node);
478
- if (!direct) continue;
479
- const summary = ensure(site.owner);
480
- summary.values.add(direct.value);
481
- if (direct.unstable) summary.unstableValues.add(direct.value);
482
- }
483
- const queue = [...graph.functions];
484
- const queued = new Set(queue);
485
- for (let index = 0; index < queue.length; index += 1) {
486
- const callee = queue[index];
487
- queued.delete(callee);
488
- const calleeSummary = ensure(callee);
489
- for (const edge of reverseEdges.get(callee) ?? []) {
490
- const ownerSummary = ensure(edge.owner);
491
- let changed = addAll(ownerSummary.values, calleeSummary.values);
492
- changed = addAll(ownerSummary.unstableValues, edge.unstable ? calleeSummary.values : calleeSummary.unstableValues) || changed;
493
- if (changed && !queued.has(edge.owner)) {
494
- queue.push(edge.owner);
495
- queued.add(edge.owner);
496
- }
497
- }
498
- }
499
- return {
500
- byFunction: mutable,
501
- forCall(call) {
502
- const target = graph.functionForCall(call);
503
- return target ? mutable.get(target) ?? null : null;
504
- }
505
- };
506
- }
507
- //#endregion
508
240
  //#region src/analyze/rules.ts
509
241
  const SOURCE_BINDING_CACHE = /* @__PURE__ */ new WeakMap();
510
242
  const SOURCE_FACT_CACHE = /* @__PURE__ */ new WeakMap();
@@ -609,34 +341,8 @@ function containingFunction(node) {
609
341
  }
610
342
  function isControlFlowAncestor(node, boundary) {
611
343
  for (let current = node.parent; current && current !== boundary; current = current.parent) {
612
- if (ts.isIfStatement(current) && !isConstantCondition(current.expression)) return true;
613
- if (ts.isConditionalExpression(current) && !isConstantCondition(current.condition) || ts.isSwitchStatement(current) || ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current) || ts.isTryStatement(current)) return true;
614
- if (ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(current.operatorToken.kind) && !isConstantCondition(current.left)) return true;
615
- }
616
- return hasConditionalEarlyExitBefore(node, boundary);
617
- }
618
- function containsFunctionExit(node) {
619
- let found = false;
620
- const walk = (candidate) => {
621
- if (found || candidate !== node && ts.isFunctionLike(candidate)) return;
622
- if (ts.isReturnStatement(candidate) || ts.isThrowStatement(candidate)) {
623
- found = true;
624
- return;
625
- }
626
- ts.forEachChild(candidate, walk);
627
- };
628
- walk(node);
629
- return found;
630
- }
631
- function hasConditionalEarlyExitBefore(node, boundary) {
632
- let child = node;
633
- for (let current = node.parent; current && current !== boundary; current = current.parent) {
634
- if (ts.isBlock(current)) {
635
- const statement = current.statements.find((candidate) => candidate === child || candidate.pos <= child.pos && child.end <= candidate.end);
636
- const index = statement ? current.statements.indexOf(statement) : -1;
637
- if (index > 0 && current.statements.slice(0, index).some((candidate) => ts.isIfStatement(candidate) && !isConstantCondition(candidate.expression) && containsFunctionExit(candidate))) return true;
638
- }
639
- child = current;
344
+ if (ts.isIfStatement(current) || ts.isConditionalExpression(current) || ts.isSwitchStatement(current) || ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current) || ts.isTryStatement(current)) return true;
345
+ if (ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(current.operatorToken.kind)) return true;
640
346
  }
641
347
  return false;
642
348
  }
@@ -648,30 +354,6 @@ function functionName(node) {
648
354
  }
649
355
  return null;
650
356
  }
651
- const EAGER_CONTROL_CONCEPTS = /* @__PURE__ */ new Set([
652
- "Case",
653
- "For",
654
- "Show"
655
- ]);
656
- function isConstantCondition(expression) {
657
- if (ts.isParenthesizedExpression(expression)) return isConstantCondition(expression.expression);
658
- return expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword || expression.kind === ts.SyntaxKind.NullKeyword || ts.isNumericLiteral(expression) || ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression);
659
- }
660
- function unstableControlConditional(node, bindings) {
661
- const ownElement = ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : null;
662
- let child = node;
663
- for (let current = node.parent; current; current = current.parent) {
664
- if (ts.isFunctionLike(current)) return null;
665
- if (current !== ownElement && ts.isJsxElement(current)) {
666
- const name = canonicalJsxName(current.openingElement.tagName, bindings);
667
- if (name && EAGER_CONTROL_CONCEPTS.has(name)) return null;
668
- }
669
- if (ts.isConditionalExpression(current) && (current.whenTrue === child || current.whenFalse === child) && !isConstantCondition(current.condition)) return current;
670
- if (ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(current.operatorToken.kind) && current.right === child && !isConstantCondition(current.left)) return current;
671
- child = current;
672
- }
673
- return null;
674
- }
675
357
  const stableRenderRule = {
676
358
  id: "askr/stable-render-call",
677
359
  category: "correctness",
@@ -679,31 +361,12 @@ const stableRenderRule = {
679
361
  description: "Render-scoped Askr primitives must have stable top-level call order.",
680
362
  analyze(context) {
681
363
  const diagnostics = [];
682
- const wrapperSummary = summarizeTransitiveCalls(context, (call) => {
683
- if (!ts.isCallExpression(call)) return null;
684
- const name = canonicalCallName(call.expression, sourceBindings(call.getSourceFile()));
685
- return name && (RENDER_SCOPED_CONCEPTS.has(name) || POSITIONAL_DATA_CONCEPTS.has(name)) ? {
686
- value: name,
687
- unstable: Boolean(containingFunction(call) && isControlFlowAncestor(call, containingFunction(call)))
688
- } : null;
689
- }, isControlFlowAncestor);
690
364
  for (const sourceFile of context.sourceFiles) {
691
365
  const bindings = sourceBindings(sourceFile);
692
366
  visit(sourceFile, (node) => {
693
367
  if (!ts.isCallExpression(node)) return;
694
368
  const name = canonicalCallName(node.expression, bindings);
695
- if (!name || !RENDER_SCOPED_CONCEPTS.has(name) && !POSITIONAL_DATA_CONCEPTS.has(name)) {
696
- const summary = wrapperSummary.forCall(node);
697
- const owner = containingFunction(node);
698
- if (!summary || summary.values.size === 0 || !owner || !/^[A-Z]/.test(functionName(owner) ?? "") && !containsJsx(owner) || !isControlFlowAncestor(node, owner) && summary.unstableValues.size === 0) return;
699
- const conditionalCallSite = isControlFlowAncestor(node, owner);
700
- const conditionalWrapperInternals = summary.unstableValues.size > 0;
701
- const concepts = [...conditionalWrapperInternals ? summary.unstableValues : summary.values].sort().join(", ");
702
- const message = conditionalCallSite && conditionalWrapperInternals ? `${node.expression.getText()}() is called conditionally and transitively contains conditionally executed render-owned Askr APIs (${concepts}).` : conditionalCallSite ? `${node.expression.getText()}() is called conditionally and transitively calls render-owned Askr APIs (${concepts}).` : `${node.expression.getText()}() transitively contains conditionally executed render-owned Askr APIs (${concepts}).`;
703
- const remediation = conditionalCallSite ? "Call the wrapper unconditionally at the top level and branch on its result or inputs." : "Make the render-owned call unconditional inside the wrapper and branch on its result or inputs.";
704
- diagnostics.push(diagnostic(context, node.expression, this, message, remediation));
705
- return;
706
- }
369
+ if (!name || !RENDER_SCOPED_CONCEPTS.has(name) && !POSITIONAL_DATA_CONCEPTS.has(name)) return;
707
370
  const owner = containingFunction(node);
708
371
  if (!owner) {
709
372
  if (POSITIONAL_DATA_CONCEPTS.has(name)) return;
@@ -712,194 +375,6 @@ const stableRenderRule = {
712
375
  }
713
376
  if (isControlFlowAncestor(node, owner) && (!POSITIONAL_DATA_CONCEPTS.has(name) || /^[A-Z]/.test(functionName(owner) ?? "") || containsJsx(owner))) diagnostics.push(diagnostic(context, node.expression, this, `${name}() is called conditionally, so its render position is unstable.`, `Call ${name}() unconditionally at the top level and branch on its result.`));
714
377
  });
715
- visit(sourceFile, (node) => {
716
- if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
717
- const name = canonicalJsxName(node.tagName, bindings);
718
- if (!name || !EAGER_CONTROL_CONCEPTS.has(name)) return;
719
- const conditional = unstableControlConditional(node, bindings);
720
- const owner = containingFunction(node);
721
- const followsEarlyReturn = Boolean(owner && hasConditionalEarlyExitBefore(node, owner));
722
- if (!conditional && !followsEarlyReturn) return;
723
- const remediation = followsEarlyReturn ? name === "For" ? "Keep <For> mounted and represent the unavailable case with an empty each source." : name === "Show" ? "Keep <Show> mounted and move the condition into its when prop." : "Keep <Case> mounted and express the condition in its branches." : name === "Show" ? "Mount <Show> unconditionally and move the condition into its when prop." : "Replace the ternary or logical expression with a <Show> boundary.";
724
- diagnostics.push(diagnostic(context, node.tagName, this, followsEarlyReturn ? `<${name}> is reached after a conditional early return, so its render position is unstable.` : `<${name}> is mounted behind a changing conditional, so its render position is unstable.`, remediation));
725
- });
726
- }
727
- return diagnostics;
728
- }
729
- };
730
- const TIMER_STARTS = /* @__PURE__ */ new Set([
731
- "requestAnimationFrame",
732
- "setInterval",
733
- "setTimeout"
734
- ]);
735
- const OBSERVER_CONSTRUCTORS = /* @__PURE__ */ new Set([
736
- "IntersectionObserver",
737
- "MutationObserver",
738
- "ResizeObserver"
739
- ]);
740
- const SUBSCRIPTION_CONSTRUCTORS = /* @__PURE__ */ new Set([
741
- "BroadcastChannel",
742
- "EventSource",
743
- "WebSocket"
744
- ]);
745
- const PLATFORM_EVENT_TARGET_TYPES = /^(?:AbortSignal|Document|EventTarget|HTMLElement|MediaQueryList|Window)(?:<.*>)?$/;
746
- const PROJECT_SOURCE_FILES = /* @__PURE__ */ new WeakMap();
747
- const CLEANUP_CALLS = /* @__PURE__ */ new WeakMap();
748
- function projectDeclaredIdentifier(identifier, context) {
749
- let symbol = context.checker.getSymbolAtLocation(identifier);
750
- if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = context.checker.getAliasedSymbol(symbol);
751
- let sourceFiles = PROJECT_SOURCE_FILES.get(context.program);
752
- if (!sourceFiles) {
753
- sourceFiles = new Set(context.sourceFiles);
754
- PROJECT_SOURCE_FILES.set(context.program, sourceFiles);
755
- }
756
- return symbol?.declarations?.some((declaration) => sourceFiles.has(declaration.getSourceFile())) ?? false;
757
- }
758
- function knownGlobalObject(expression, context) {
759
- return ts.isIdentifier(expression) && [
760
- "document",
761
- "globalThis",
762
- "window"
763
- ].includes(expression.text) && !projectDeclaredIdentifier(expression, context);
764
- }
765
- function typedPlatformEventTarget(expression, context) {
766
- if (knownGlobalObject(expression, context)) return true;
767
- if (!ts.isIdentifier(expression)) return false;
768
- const symbol = context.checker.getSymbolAtLocation(expression);
769
- return Boolean(symbol?.declarations?.some((declaration) => {
770
- if (!ts.isVariableDeclaration(declaration) && !ts.isParameter(declaration) && !ts.isPropertyDeclaration(declaration)) return false;
771
- return Boolean(declaration.type && PLATFORM_EVENT_TARGET_TYPES.test(declaration.type.getText()));
772
- }));
773
- }
774
- function directGlobalCall(call, names, context) {
775
- if (ts.isIdentifier(call.expression) && names.has(call.expression.text) && !projectDeclaredIdentifier(call.expression, context)) return call.expression.text;
776
- if (ts.isPropertyAccessExpression(call.expression) && names.has(call.expression.name.text) && knownGlobalObject(call.expression.expression, context)) return call.expression.name.text;
777
- return null;
778
- }
779
- function platformConstructorName(expression, context) {
780
- if (ts.isIdentifier(expression) && !projectDeclaredIdentifier(expression, context)) return expression.text;
781
- if (ts.isPropertyAccessExpression(expression) && knownGlobalObject(expression.expression, context)) return expression.name.text;
782
- return null;
783
- }
784
- function renderSideEffect(node, context) {
785
- if (ts.isNewExpression(node)) {
786
- const name = platformConstructorName(node.expression, context);
787
- if (name && OBSERVER_CONSTRUCTORS.has(name)) return "observer";
788
- if (name && SUBSCRIPTION_CONSTRUCTORS.has(name)) return "subscription";
789
- return null;
790
- }
791
- if (directGlobalCall(node, TIMER_STARTS, context)) return "timer";
792
- if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "addEventListener" && typedPlatformEventTarget(node.expression.expression, context)) return "listener";
793
- if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "watchPosition" && ts.isPropertyAccessExpression(node.expression.expression) && node.expression.expression.name.text === "geolocation" && ts.isIdentifier(node.expression.expression.expression) && node.expression.expression.expression.text === "navigator" && !projectDeclaredIdentifier(node.expression.expression.expression, context)) return "subscription";
794
- return null;
795
- }
796
- function terminalCleanupFunction(callback, context) {
797
- if (!("body" in callback) || !callback.body) return null;
798
- let returned;
799
- if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) returned = callback.body;
800
- else if (ts.isBlock(callback.body)) {
801
- const last = callback.body.statements.at(-1);
802
- if (last && ts.isReturnStatement(last)) returned = last.expression;
803
- }
804
- return resolvedFunction(returned, context);
805
- }
806
- function cleanupCalls(callback, context) {
807
- const cached = CLEANUP_CALLS.get(callback);
808
- if (cached) return cached;
809
- const cleanup = terminalCleanupFunction(callback, context);
810
- if (!cleanup) {
811
- CLEANUP_CALLS.set(callback, []);
812
- return [];
813
- }
814
- const calls = [];
815
- const walk = (node) => {
816
- if (node !== cleanup && ts.isFunctionLike(node)) return;
817
- if (ts.isCallExpression(node)) calls.push(node);
818
- ts.forEachChild(node, walk);
819
- };
820
- walk(cleanup);
821
- CLEANUP_CALLS.set(callback, calls);
822
- return calls;
823
- }
824
- function assignedEffectReference(node) {
825
- const parent = node.parent;
826
- if (ts.isVariableDeclaration(parent) && parent.initializer === node) return ts.isIdentifier(parent.name) ? parent.name : null;
827
- if (ts.isBinaryExpression(parent) && parent.right === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) return parent.left;
828
- return null;
829
- }
830
- function sameReference(left, right, context) {
831
- if (!left || !right) return false;
832
- let leftSymbol = context.checker.getSymbolAtLocation(left);
833
- let rightSymbol = context.checker.getSymbolAtLocation(right);
834
- if (leftSymbol && (leftSymbol.flags & ts.SymbolFlags.Alias) !== 0) leftSymbol = context.checker.getAliasedSymbol(leftSymbol);
835
- if (rightSymbol && (rightSymbol.flags & ts.SymbolFlags.Alias) !== 0) rightSymbol = context.checker.getAliasedSymbol(rightSymbol);
836
- return leftSymbol && rightSymbol ? leftSymbol === rightSymbol : left.getText() === right.getText();
837
- }
838
- function taskCleansDirectEffect(callback, node, effect, context) {
839
- const cleanup = cleanupCalls(callback, context);
840
- if (effect === "listener" && ts.isCallExpression(node)) {
841
- if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "addEventListener") return false;
842
- const startExpression = node.expression;
843
- return cleanup.some((call) => ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "removeEventListener" && sameReference(call.expression.expression, startExpression.expression, context) && sameReference(call.arguments[0], node.arguments[0], context) && sameReference(call.arguments[1], node.arguments[1], context) && (node.arguments[2] === void 0 || sameReference(call.arguments[2], node.arguments[2], context)));
844
- }
845
- const handle = assignedEffectReference(node);
846
- if (!handle) return false;
847
- if (effect === "timer" && ts.isCallExpression(node)) {
848
- const start = directGlobalCall(node, TIMER_STARTS, context);
849
- const expected = start === "setInterval" ? "clearInterval" : start === "setTimeout" ? "clearTimeout" : start === "requestAnimationFrame" ? "cancelAnimationFrame" : null;
850
- const expectedSet = expected ? /* @__PURE__ */ new Set([expected]) : /* @__PURE__ */ new Set();
851
- return cleanup.some((call) => Boolean(directGlobalCall(call, expectedSet, context)) && sameReference(call.arguments[0], handle, context));
852
- }
853
- if (effect === "observer") return cleanup.some((call) => ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "disconnect" && sameReference(call.expression.expression, handle, context));
854
- if (effect === "subscription") return cleanup.some((call) => {
855
- if (ts.isPropertyAccessExpression(call.expression) && (call.expression.name.text === "close" || call.expression.name.text === "unsubscribe") && sameReference(call.expression.expression, handle, context)) return true;
856
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "clearWatch" && ts.isPropertyAccessExpression(call.expression.expression) && call.expression.expression.name.text === "geolocation" && ts.isIdentifier(call.expression.expression.expression) && call.expression.expression.expression.text === "navigator" && !projectDeclaredIdentifier(call.expression.expression.expression, context) && sameReference(call.arguments[0], handle, context);
857
- });
858
- return false;
859
- }
860
- function isRenderFunction(owner) {
861
- return /^[A-Z]/.test(functionName(owner) ?? "") || containsJsx(owner);
862
- }
863
- const renderSideEffectRule = {
864
- id: "askr/render-side-effect",
865
- category: "correctness",
866
- severity: "error",
867
- description: "Platform side effects started during render require lifecycle-owned cleanup.",
868
- analyze(context) {
869
- const diagnostics = [];
870
- const graph = localCallGraph(context);
871
- const summary = summarizeTransitiveCalls(context, (node) => {
872
- const effect = renderSideEffect(node, context);
873
- return effect ? { value: effect } : null;
874
- }, () => false);
875
- const lifecycleCallbacks = /* @__PURE__ */ new Set();
876
- for (const site of graph.calls) {
877
- if (canonicalCallName(site.node.expression, sourceBindings(site.node.getSourceFile())) !== "task") continue;
878
- const argument = site.node.arguments[0];
879
- const callback = resolvedFunction(argument, context) ?? (argument ? graph.functionForExpression(argument) : null);
880
- if (callback) lifecycleCallbacks.add(callback);
881
- }
882
- const report = (node, effects, direct = false) => {
883
- const owner = containingFunction(node);
884
- if (!owner) return;
885
- const managed = lifecycleCallbacks.has(owner);
886
- if (managed && direct && [...effects].every((effect) => taskCleansDirectEffect(owner, node, effect, context))) return;
887
- if (!managed && !isRenderFunction(owner)) return;
888
- const effectNames = [...effects].sort().join(", ");
889
- diagnostics.push(diagnostic(context, node.expression, this, managed ? `This task starts ${effectNames} side effects without returning matching cleanup.` : `${node.expression.getText()} starts unmanaged ${effectNames} side effects during render.`, "Start the effect in task() and return cleanup that clears, disconnects, removes, closes, or unsubscribes it."));
890
- };
891
- for (const site of graph.calls) {
892
- const direct = renderSideEffect(site.node, context);
893
- if (direct) {
894
- report(site.node, /* @__PURE__ */ new Set([direct]), true);
895
- continue;
896
- }
897
- const wrapper = summary.forCall(site.node);
898
- if (wrapper && wrapper.values.size > 0) report(site.node, wrapper.values);
899
- }
900
- for (const site of graph.constructions) {
901
- const direct = renderSideEffect(site.node, context);
902
- if (direct) report(site.node, /* @__PURE__ */ new Set([direct]), true);
903
378
  }
904
379
  return diagnostics;
905
380
  }
@@ -1119,44 +594,30 @@ const stableKeyRule = {
1119
594
  return diagnostics;
1120
595
  }
1121
596
  };
1122
- function mapResultIsJsxChild(node) {
1123
- let current = node;
1124
- for (;;) {
1125
- const parent = current.parent;
1126
- if (ts.isJsxExpression(parent)) return !ts.isJsxAttribute(parent.parent);
1127
- if ((ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent) || ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent) || ts.isSatisfiesExpression(parent)) && parent.expression === current) {
1128
- current = parent;
1129
- continue;
1130
- }
1131
- if (ts.isConditionalExpression(parent) && (parent.whenTrue === current || parent.whenFalse === current)) {
1132
- current = parent;
1133
- continue;
1134
- }
1135
- if (ts.isBinaryExpression(parent) && (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && parent.right === current || parent.operatorToken.kind === ts.SyntaxKind.BarBarToken && (parent.left === current || parent.right === current) || parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && (parent.left === current || parent.right === current))) {
1136
- current = parent;
1137
- continue;
1138
- }
1139
- return false;
1140
- }
597
+ function reactiveMapReceiver(expression, stateGetters) {
598
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) return stateGetters.has(expression.expression.text);
599
+ if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) return reactiveMapReceiver(expression.expression.expression, stateGetters);
600
+ if (ts.isPropertyAccessExpression(expression)) return reactiveMapReceiver(expression.expression, stateGetters);
601
+ return false;
1141
602
  }
1142
603
  const preferForRule = {
1143
604
  id: "askr/prefer-for",
1144
605
  category: "performance",
1145
606
  severity: "warning",
1146
- description: "Collection arrays rendered as JSX children should use For.",
607
+ description: "Reactive JSX collections should use For for keyed reconciliation.",
1147
608
  analyze(context) {
1148
609
  const diagnostics = [];
1149
- for (const sourceFile of context.sourceFiles) visit(sourceFile, (node) => {
1150
- if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "map" || !mapResultIsJsxChild(node)) return;
1151
- diagnostics.push(diagnostic(context, node.expression.name, this, "A collection array is rendered with .map(), bypassing keyed <For> reconciliation.", "Render it with <For each={...} by={...}>. This semantic rewrite is report-only."));
1152
- });
610
+ for (const sourceFile of context.sourceFiles) {
611
+ const state = collectStateBindings(sourceFile, sourceBindings(sourceFile));
612
+ visit(sourceFile, (node) => {
613
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "map" || !reactiveMapReceiver(node.expression.expression, state.getters) || !node.parent || !ts.isJsxExpression(node.parent)) return;
614
+ diagnostics.push(diagnostic(context, node.expression.name, this, "A reactive collection is rendered with .map(), bypassing keyed <For> reconciliation.", "Render it with <For each={...} by={...}>. This semantic rewrite is report-only."));
615
+ });
616
+ }
1153
617
  return diagnostics;
1154
618
  }
1155
619
  };
1156
- const CONTAINS_JSX = /* @__PURE__ */ new WeakMap();
1157
620
  function containsJsx(node) {
1158
- const cached = CONTAINS_JSX.get(node);
1159
- if (cached !== void 0) return cached;
1160
621
  let found = false;
1161
622
  const walk = (candidate) => {
1162
623
  if (found) return;
@@ -1167,7 +628,6 @@ function containsJsx(node) {
1167
628
  ts.forEachChild(candidate, walk);
1168
629
  };
1169
630
  walk(node);
1170
- CONTAINS_JSX.set(node, found);
1171
631
  return found;
1172
632
  }
1173
633
  function isAsyncFunction(node) {
@@ -1766,7 +1226,6 @@ const ANALYZE_RULES = [
1766
1226
  }
1767
1227
  },
1768
1228
  stableRenderRule,
1769
- renderSideEffectRule,
1770
1229
  stateAccessRule,
1771
1230
  stateRenderWriteRule,
1772
1231
  resourceCancellationRule,