@askrjs/cli 0.0.15 → 0.0.17

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/**",
@@ -169,7 +168,8 @@ const ASKR_CONCEPTS = {
169
168
  "task",
170
169
  "timer",
171
170
  "stream",
172
- "on"
171
+ "on",
172
+ "onRouteChange"
173
173
  ],
174
174
  data: [
175
175
  "createQuery",
@@ -238,273 +238,6 @@ const RENDER_SCOPED_CONCEPTS = /* @__PURE__ */ new Set([
238
238
  const POSITIONAL_DATA_CONCEPTS = /* @__PURE__ */ new Set(["createQuery", "createMutation"]);
239
239
  const ASKR_MODULE_PATTERN = /^@askrjs\/askr(?:\/|$)/;
240
240
  //#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
241
  //#region src/analyze/rules.ts
509
242
  const SOURCE_BINDING_CACHE = /* @__PURE__ */ new WeakMap();
510
243
  const SOURCE_FACT_CACHE = /* @__PURE__ */ new WeakMap();
@@ -609,34 +342,8 @@ function containingFunction(node) {
609
342
  }
610
343
  function isControlFlowAncestor(node, boundary) {
611
344
  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;
345
+ 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;
346
+ if (ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(current.operatorToken.kind)) return true;
640
347
  }
641
348
  return false;
642
349
  }
@@ -648,30 +355,6 @@ function functionName(node) {
648
355
  }
649
356
  return null;
650
357
  }
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
358
  const stableRenderRule = {
676
359
  id: "askr/stable-render-call",
677
360
  category: "correctness",
@@ -679,31 +362,12 @@ const stableRenderRule = {
679
362
  description: "Render-scoped Askr primitives must have stable top-level call order.",
680
363
  analyze(context) {
681
364
  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
365
  for (const sourceFile of context.sourceFiles) {
691
366
  const bindings = sourceBindings(sourceFile);
692
367
  visit(sourceFile, (node) => {
693
368
  if (!ts.isCallExpression(node)) return;
694
369
  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
- }
370
+ if (!name || !RENDER_SCOPED_CONCEPTS.has(name) && !POSITIONAL_DATA_CONCEPTS.has(name)) return;
707
371
  const owner = containingFunction(node);
708
372
  if (!owner) {
709
373
  if (POSITIONAL_DATA_CONCEPTS.has(name)) return;
@@ -712,194 +376,6 @@ const stableRenderRule = {
712
376
  }
713
377
  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
378
  });
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
379
  }
904
380
  return diagnostics;
905
381
  }
@@ -1090,6 +566,12 @@ const controlContractRule = {
1090
566
  const parentOpening = ts.isJsxElement(parentElement) ? parentElement.openingElement : null;
1091
567
  if (!parentOpening || canonicalJsxName(parentOpening.tagName, bindings) !== "Case") diagnostics.push(diagnostic(context, node.tagName, this, "<Match> may only be used as a direct child of <Case>.", "Move this branch directly inside a <Case> boundary."));
1092
568
  }
569
+ if (name === "Case" && ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent)) for (const child of node.parent.children) {
570
+ if (ts.isJsxText(child) && child.text.trim() === "") continue;
571
+ if (ts.isJsxExpression(child) && (!child.expression || child.expression.kind === ts.SyntaxKind.NullKeyword || child.expression.kind === ts.SyntaxKind.FalseKeyword)) continue;
572
+ if (ts.isJsxElement(child) && canonicalJsxName(child.openingElement.tagName, bindings) === "Match") continue;
573
+ diagnostics.push(diagnostic(context, child, this, "<Case> may only contain direct <Match> branches, null, false, or whitespace.", "Move non-branch content into a <Match> child."));
574
+ }
1093
575
  });
1094
576
  }
1095
577
  return diagnostics;
@@ -1119,44 +601,30 @@ const stableKeyRule = {
1119
601
  return diagnostics;
1120
602
  }
1121
603
  };
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
- }
604
+ function reactiveMapReceiver(expression, stateGetters) {
605
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) return stateGetters.has(expression.expression.text);
606
+ if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) return reactiveMapReceiver(expression.expression.expression, stateGetters);
607
+ if (ts.isPropertyAccessExpression(expression)) return reactiveMapReceiver(expression.expression, stateGetters);
608
+ return false;
1141
609
  }
1142
610
  const preferForRule = {
1143
611
  id: "askr/prefer-for",
1144
612
  category: "performance",
1145
613
  severity: "warning",
1146
- description: "Collection arrays rendered as JSX children should use For.",
614
+ description: "Reactive JSX collections should use For for keyed reconciliation.",
1147
615
  analyze(context) {
1148
616
  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
- });
617
+ for (const sourceFile of context.sourceFiles) {
618
+ const state = collectStateBindings(sourceFile, sourceBindings(sourceFile));
619
+ visit(sourceFile, (node) => {
620
+ 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;
621
+ 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."));
622
+ });
623
+ }
1153
624
  return diagnostics;
1154
625
  }
1155
626
  };
1156
- const CONTAINS_JSX = /* @__PURE__ */ new WeakMap();
1157
627
  function containsJsx(node) {
1158
- const cached = CONTAINS_JSX.get(node);
1159
- if (cached !== void 0) return cached;
1160
628
  let found = false;
1161
629
  const walk = (candidate) => {
1162
630
  if (found) return;
@@ -1167,7 +635,6 @@ function containsJsx(node) {
1167
635
  ts.forEachChild(candidate, walk);
1168
636
  };
1169
637
  walk(node);
1170
- CONTAINS_JSX.set(node, found);
1171
638
  return found;
1172
639
  }
1173
640
  function isAsyncFunction(node) {
@@ -1466,6 +933,7 @@ const dataContractRule = {
1466
933
  const key = optionExpression(options, "key");
1467
934
  if (!key || literalString(key) !== null && literalString(key)?.trim() === "") diagnostics.push(diagnostic(context, key ?? options, this, "createQuery() requires a non-empty key.", "Pass a stable query key."));
1468
935
  else if (ts.isLiteralExpression(key) && !ts.isStringLiteralLike(key)) diagnostics.push(diagnostic(context, key, this, "createQuery() key must be a string or key function."));
936
+ else if (ts.isObjectLiteralExpression(key) || ts.isArrayLiteralExpression(key)) diagnostics.push(diagnostic(context, key, this, "createQuery() key must not be an object or array allocation.", "Use a stable string or a key function that returns the runtime-supported key shape."));
1469
937
  const fetcher = optionExpression(options, "fetch");
1470
938
  if (!fetcher || isProvablyNonFunction(fetcher)) diagnostics.push(diagnostic(context, fetcher ?? options, this, "createQuery() requires a fetch function.", "Pass a cancellable fetch function."));
1471
939
  } else {
@@ -1741,32 +1209,448 @@ function hasDependency(manifest, packageName) {
1741
1209
  "optionalDependencies"
1742
1210
  ].some((section) => packageName in dependencyRecord(manifest, section));
1743
1211
  }
1744
- const ANALYZE_RULES = [
1745
- {
1746
- id: "askr/parse-error",
1747
- category: "correctness",
1748
- severity: "error",
1749
- description: "Source must parse before framework analysis is reliable.",
1750
- analyze(context) {
1751
- return context.program.getSyntacticDiagnostics().filter((entry) => Boolean(entry.file && context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName))).map((entry) => {
1752
- const start = entry.start ?? 0;
1753
- const point = entry.file.getLineAndCharacterOfPosition(start);
1754
- return {
1755
- ruleId: this.id,
1756
- category: this.category,
1757
- severity: this.severity,
1758
- message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"),
1759
- workspace: context.workspace.name,
1760
- file: workspaceRelativeFile(context, entry.file.fileName),
1761
- line: point.line + 1,
1762
- column: point.character + 1,
1763
- remediation: "Fix the syntax error so framework rules can inspect this file reliably."
1212
+ const frameworkConfigRule = {
1213
+ id: "askr/framework-config",
1214
+ category: "configuration",
1215
+ severity: "error",
1216
+ description: "TypeScript and Vite must use Askr's JSX/runtime wiring.",
1217
+ analyze(context) {
1218
+ const diagnostics = [];
1219
+ const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx"));
1220
+ if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics;
1221
+ const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json");
1222
+ const tsconfigSource = ts.sys.readFile(tsconfigPath);
1223
+ if (context.program.getCompilerOptions().jsxImportSource !== "@askrjs/askr") {
1224
+ let fix;
1225
+ if (tsconfigSource) try {
1226
+ const parsed = JSON.parse(tsconfigSource);
1227
+ parsed.compilerOptions = {
1228
+ ...parsed.compilerOptions && typeof parsed.compilerOptions === "object" && !Array.isArray(parsed.compilerOptions) ? parsed.compilerOptions : {},
1229
+ jsx: "react-jsx",
1230
+ jsxImportSource: "@askrjs/askr"
1231
+ };
1232
+ fix = {
1233
+ description: "Configure TypeScript to use the Askr JSX runtime",
1234
+ filePath: tsconfigPath,
1235
+ start: 0,
1236
+ end: tsconfigSource.length,
1237
+ replacement: `${JSON.stringify(parsed, null, 2)}\n`
1764
1238
  };
1239
+ } catch {}
1240
+ diagnostics.push(diagnostic(context, tsx, this, "TSX is present but compilerOptions.jsxImportSource is not '@askrjs/askr'.", "Set jsx to react-jsx and jsxImportSource to @askrjs/askr.", fix));
1241
+ }
1242
+ const viteConfig = context.sourceFiles.find((sourceFile) => /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")));
1243
+ if (viteConfig) {
1244
+ let pluginLocalName = null;
1245
+ for (const statement of viteConfig.statements) {
1246
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/vite" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1247
+ pluginLocalName = statement.importClause.namedBindings.elements.find((element) => (element.propertyName?.text ?? element.name.text) === "askr")?.name.text ?? null;
1248
+ }
1249
+ let pluginCalled = false;
1250
+ if (pluginLocalName) visit(viteConfig, (node) => {
1251
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === pluginLocalName) pluginCalled = true;
1765
1252
  });
1253
+ const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite");
1254
+ if (dependencyPresent && pluginCalled) return diagnostics;
1255
+ diagnostics.push(diagnostic(context, viteConfig, this, !dependencyPresent ? "This Askr Vite project does not declare @askrjs/vite." : "Vite is configured without calling the @askrjs/vite askr() plugin.", "Declare @askrjs/vite, import askr, and include askr() in the plugin list."));
1766
1256
  }
1767
- },
1257
+ return diagnostics;
1258
+ }
1259
+ };
1260
+ const parseErrorRule = {
1261
+ id: "askr/parse-error",
1262
+ category: "correctness",
1263
+ severity: "error",
1264
+ description: "Source must parse before framework analysis is reliable.",
1265
+ analyze(context) {
1266
+ return context.program.getSyntacticDiagnostics().filter((entry) => Boolean(entry.file && context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName))).map((entry) => {
1267
+ const start = entry.start ?? 0;
1268
+ const point = entry.file.getLineAndCharacterOfPosition(start);
1269
+ return {
1270
+ ruleId: this.id,
1271
+ category: this.category,
1272
+ severity: this.severity,
1273
+ message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"),
1274
+ workspace: context.workspace.name,
1275
+ file: workspaceRelativeFile(context, entry.file.fileName),
1276
+ line: point.line + 1,
1277
+ column: point.character + 1,
1278
+ remediation: "Fix the syntax error so framework rules can inspect this file reliably."
1279
+ };
1280
+ });
1281
+ }
1282
+ };
1283
+ function nearestComponent(node) {
1284
+ for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current)) {
1285
+ const name = functionName(current);
1286
+ if (name && /^[A-Z]/.test(name) || containsJsx(current)) return current;
1287
+ }
1288
+ return null;
1289
+ }
1290
+ function isInsideNestedFunction(node, owner) {
1291
+ for (let current = node.parent; current && current !== owner; current = current.parent) if (ts.isFunctionLike(current)) return true;
1292
+ return false;
1293
+ }
1294
+ function jsxExpression(attribute) {
1295
+ if (!attribute || !ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer)) return null;
1296
+ return attribute.initializer.expression ?? null;
1297
+ }
1298
+ const stableControlBoundaryRule = {
1299
+ id: "askr/stable-control-boundary",
1300
+ category: "correctness",
1301
+ severity: "error",
1302
+ description: "Conditional control boundaries must not change identity between renders.",
1303
+ analyze(context) {
1304
+ const diagnostics = [];
1305
+ for (const sourceFile of context.sourceFiles) {
1306
+ const bindings = sourceBindings(sourceFile);
1307
+ const facts = sourceFacts(sourceFile);
1308
+ if (!facts.jsx.some((fact) => [
1309
+ "For",
1310
+ "Show",
1311
+ "Case"
1312
+ ].includes(fact.name)) && !facts.calls.some((fact) => fact.name === "defineScope")) continue;
1313
+ visit(sourceFile, (node) => {
1314
+ let candidate = null;
1315
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && [
1316
+ "For",
1317
+ "Show",
1318
+ "Case"
1319
+ ].includes(canonicalJsxName(node.tagName, bindings) ?? "")) candidate = node;
1320
+ else if (ts.isCallExpression(node) && canonicalCallName(node.expression, bindings) === "defineScope") candidate = node;
1321
+ if (!candidate) return;
1322
+ const owner = nearestComponent(candidate);
1323
+ if (!owner || !isControlFlowAncestor(candidate, owner)) return;
1324
+ diagnostics.push(diagnostic(context, candidate, this, "An Askr control boundary is created conditionally, so its render identity is unstable.", "Create the boundary unconditionally and put the condition in <Show>, <Match>, or its inputs."));
1325
+ });
1326
+ }
1327
+ return diagnostics;
1328
+ }
1329
+ };
1330
+ function dependencyNames(array) {
1331
+ const names = /* @__PURE__ */ new Set();
1332
+ for (const element of array.elements) {
1333
+ if (ts.isIdentifier(element)) names.add(element.text);
1334
+ if (ts.isCallExpression(element) && ts.isIdentifier(element.expression)) names.add(element.expression.text);
1335
+ }
1336
+ return names;
1337
+ }
1338
+ const exhaustiveDependenciesRule = {
1339
+ id: "askr/exhaustive-dependencies",
1340
+ category: "correctness",
1341
+ severity: "warning",
1342
+ description: "Resource and stream dependency arrays must list directly read reactive values.",
1343
+ analyze(context) {
1344
+ const diagnostics = [];
1345
+ for (const sourceFile of context.sourceFiles) {
1346
+ const bindings = sourceBindings(sourceFile);
1347
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "resource" || fact.name === "stream")) continue;
1348
+ const reactive = collectStateBindings(sourceFile, bindings).getters;
1349
+ for (const { node, name } of sourceFacts(sourceFile).calls) {
1350
+ if (name !== "resource" && name !== "stream") continue;
1351
+ const loader = node.arguments[0];
1352
+ const deps = name === "resource" ? node.arguments[1] : node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1]) ? optionExpression(node.arguments[1], "deps") : node.arguments[1];
1353
+ if (!loader || !ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader) || !deps || !ts.isArrayLiteralExpression(deps) || deps.elements.some(ts.isSpreadElement)) continue;
1354
+ const declared = dependencyNames(deps);
1355
+ const missing = /* @__PURE__ */ new Set();
1356
+ const walk = (candidate) => {
1357
+ if (candidate !== loader && ts.isFunctionLike(candidate)) return;
1358
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && reactive.has(candidate.expression.text) && !declared.has(candidate.expression.text)) missing.add(candidate.expression.text);
1359
+ ts.forEachChild(candidate, walk);
1360
+ };
1361
+ walk(loader.body);
1362
+ for (const name of [...missing].sort()) diagnostics.push(diagnostic(context, loader, this, `${name}() is read by ${canonicalCallName(node.expression, bindings)}() but is missing from its dependency array.`, `Add ${name}() to the literal dependency array.`));
1363
+ }
1364
+ }
1365
+ return diagnostics;
1366
+ }
1367
+ };
1368
+ const forRowClosureCaptureRule = {
1369
+ id: "askr/for-row-closure-capture",
1370
+ category: "correctness",
1371
+ severity: "warning",
1372
+ description: "For row renderers must not capture changing component reactive values.",
1373
+ analyze(context) {
1374
+ const diagnostics = [];
1375
+ for (const sourceFile of context.sourceFiles) {
1376
+ const bindings = sourceBindings(sourceFile);
1377
+ if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "For")) continue;
1378
+ const reactive = collectStateBindings(sourceFile, bindings).getters;
1379
+ const snapshots = /* @__PURE__ */ new Set();
1380
+ visit(sourceFile, (candidate) => {
1381
+ if (ts.isVariableDeclaration(candidate) && ts.isIdentifier(candidate.name) && candidate.initializer && ts.isCallExpression(candidate.initializer) && ts.isIdentifier(candidate.initializer.expression) && reactive.has(candidate.initializer.expression.text)) snapshots.add(candidate.name.text);
1382
+ });
1383
+ visit(sourceFile, (node) => {
1384
+ if (!ts.isJsxElement(node) || canonicalJsxName(node.openingElement.tagName, bindings) !== "For") return;
1385
+ for (const child of node.children) {
1386
+ if (!ts.isJsxExpression(child) || !child.expression || !ts.isArrowFunction(child.expression) && !ts.isFunctionExpression(child.expression)) continue;
1387
+ const renderer = child.expression;
1388
+ const captured = /* @__PURE__ */ new Set();
1389
+ const walk = (candidate) => {
1390
+ for (let current = candidate.parent; current && current !== renderer; current = current.parent) if (ts.isJsxAttribute(current)) return;
1391
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && reactive.has(candidate.expression.text)) captured.add(candidate.expression.text);
1392
+ if (ts.isIdentifier(candidate) && snapshots.has(candidate.text) && !(ts.isPropertyAccessExpression(candidate.parent) && candidate.parent.name === candidate)) captured.add(candidate.text);
1393
+ ts.forEachChild(candidate, walk);
1394
+ };
1395
+ walk(renderer.body);
1396
+ for (const name of [...captured].sort()) diagnostics.push(diagnostic(context, renderer, this, `<For> row rendering captures reactive value '${name}' from its component closure.`, "Read changing values through row data, a selector predicate, or a function-valued JSX prop."));
1397
+ }
1398
+ });
1399
+ }
1400
+ return diagnostics;
1401
+ }
1402
+ };
1403
+ const renderScopeRequiredRule = {
1404
+ id: "askr/render-scope-required",
1405
+ category: "correctness",
1406
+ severity: "error",
1407
+ description: "Render-scoped APIs cannot be created in callbacks that execute outside rendering.",
1408
+ analyze(context) {
1409
+ const diagnostics = [];
1410
+ const callbackCalls = /* @__PURE__ */ new Set([
1411
+ "setTimeout",
1412
+ "setInterval",
1413
+ "queueMicrotask",
1414
+ "then",
1415
+ "catch",
1416
+ "finally"
1417
+ ]);
1418
+ for (const sourceFile of context.sourceFiles) {
1419
+ const bindings = sourceBindings(sourceFile);
1420
+ if (!sourceFacts(sourceFile).calls.some((fact) => RENDER_SCOPED_CONCEPTS.has(fact.name))) continue;
1421
+ visit(sourceFile, (node) => {
1422
+ if (!ts.isCallExpression(node)) return;
1423
+ const name = canonicalCallName(node.expression, bindings);
1424
+ if (!name || !RENDER_SCOPED_CONCEPTS.has(name) || name === "readScope") return;
1425
+ for (let current = node.parent; current; current = current.parent) {
1426
+ if (!ts.isFunctionLike(current)) continue;
1427
+ const parent = current.parent;
1428
+ if (!(ts.isCallExpression(parent) && (ts.isIdentifier(parent.expression) && callbackCalls.has(parent.expression.text) || ts.isPropertyAccessExpression(parent.expression) && callbackCalls.has(parent.expression.name.text)) || ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent) || ts.isPropertyAssignment(parent) && parent.name.getText() === "body")) continue;
1429
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is created in a callback that is statically outside component rendering.`, `Create ${name}() at component render scope and use its value from the callback.`));
1430
+ return;
1431
+ }
1432
+ });
1433
+ }
1434
+ return diagnostics;
1435
+ }
1436
+ };
1437
+ const stableModuleIdentityRule = {
1438
+ id: "askr/stable-module-identity",
1439
+ category: "correctness",
1440
+ severity: "error",
1441
+ description: "Lazy modules and scopes must have stable identity across renders.",
1442
+ analyze(context) {
1443
+ const diagnostics = [];
1444
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1445
+ if (name !== "lazy" && name !== "defineScope") continue;
1446
+ const owner = nearestComponent(node);
1447
+ if (!owner || isInsideNestedFunction(node, owner)) continue;
1448
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is created during component rendering and receives a new identity each render.`, `Move ${name}() to module scope.`));
1449
+ }
1450
+ return diagnostics;
1451
+ }
1452
+ };
1453
+ const queryKeyContractRule = {
1454
+ id: "askr/query-key-contract",
1455
+ category: "correctness",
1456
+ severity: "error",
1457
+ description: "Query keys and scopes must be deterministic and serializable.",
1458
+ analyze(context) {
1459
+ const diagnostics = [];
1460
+ const nondeterministic = /* @__PURE__ */ new Set([
1461
+ "random",
1462
+ "now",
1463
+ "randomUUID"
1464
+ ]);
1465
+ for (const sourceFile of context.sourceFiles) {
1466
+ const bindings = sourceBindings(sourceFile);
1467
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "createQuery" || fact.name === "queryScope")) continue;
1468
+ for (const call of sourceFacts(sourceFile).allCalls) {
1469
+ const name = canonicalCallName(call.expression, bindings);
1470
+ let expression;
1471
+ if (name === "createQuery") {
1472
+ const options = call.arguments[0];
1473
+ if (options && ts.isObjectLiteralExpression(options)) expression = optionExpression(options, "key");
1474
+ } else if (name === "queryScope") expression = call.arguments[0];
1475
+ else continue;
1476
+ if (!expression) continue;
1477
+ let invalid = null;
1478
+ const walk = (node) => {
1479
+ if (invalid) return;
1480
+ if (ts.isCallExpression(node) && (ts.isPropertyAccessExpression(node.expression) && nondeterministic.has(node.expression.name.text) || ts.isIdentifier(node.expression) && node.expression.text === "Symbol")) {
1481
+ invalid = node;
1482
+ return;
1483
+ }
1484
+ ts.forEachChild(node, walk);
1485
+ };
1486
+ walk(expression);
1487
+ if (!invalid) continue;
1488
+ diagnostics.push(diagnostic(context, invalid, this, `${name}() contains a directly provable nondeterministic or Symbol key part.`, "Use stable serializable primitives derived from route, props, or state."));
1489
+ }
1490
+ }
1491
+ return diagnostics;
1492
+ }
1493
+ };
1494
+ const routeScopeStructureRule = {
1495
+ id: "askr/route-scope-structure",
1496
+ category: "correctness",
1497
+ severity: "error",
1498
+ description: "Nested page route scopes must have one index and relative child routes.",
1499
+ analyze(context) {
1500
+ const diagnostics = [];
1501
+ for (const sourceFile of context.sourceFiles) {
1502
+ const bindings = sourceBindings(sourceFile);
1503
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "page")) continue;
1504
+ const inspectBody = (body) => {
1505
+ let indexes = 0;
1506
+ const walk = (node) => {
1507
+ if (node !== body && ts.isFunctionLike(node)) return;
1508
+ if (!ts.isCallExpression(node)) {
1509
+ ts.forEachChild(node, walk);
1510
+ return;
1511
+ }
1512
+ const name = canonicalCallName(node.expression, bindings);
1513
+ if (name === "index") {
1514
+ indexes += 1;
1515
+ if (indexes > 1) diagnostics.push(diagnostic(context, node.expression, this, "A page scope declares more than one index route.", "Keep exactly one index() declaration in a page scope."));
1516
+ }
1517
+ if (name === "route") {
1518
+ const routePath = node.arguments[0];
1519
+ if (routePath && ts.isStringLiteral(routePath) && routePath.text.startsWith("/") && routePath.text.length > 1) {
1520
+ const replacement = routePath.text.replace(/^\/+/, "");
1521
+ diagnostics.push(diagnostic(context, routePath, this, `Child route '${routePath.text}' is absolute inside a page scope.`, `Use the relative child path '${replacement}'.`, {
1522
+ description: "Strip the leading slash from a proven child route",
1523
+ filePath: sourceFile.fileName,
1524
+ start: routePath.getStart(sourceFile),
1525
+ end: routePath.getEnd(),
1526
+ replacement: JSON.stringify(replacement)
1527
+ }));
1528
+ }
1529
+ }
1530
+ ts.forEachChild(node, walk);
1531
+ };
1532
+ walk(body);
1533
+ };
1534
+ for (const { node, name } of sourceFacts(sourceFile).calls) {
1535
+ if (name !== "page") continue;
1536
+ const callback = node.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
1537
+ if (callback) inspectBody(callback.body);
1538
+ }
1539
+ }
1540
+ return diagnostics;
1541
+ }
1542
+ };
1543
+ const IMPORT_SUBPATHS = {
1544
+ ActionForm: "actions",
1545
+ action: "actions",
1546
+ defineAction: "actions",
1547
+ createSPA: "boot",
1548
+ hydrateSPA: "boot",
1549
+ createIsland: "boot",
1550
+ createIslands: "boot",
1551
+ createQuery: "data",
1552
+ createMutation: "data",
1553
+ invalidate: "data",
1554
+ invalidateOnInterval: "data",
1555
+ queryScope: "data",
1556
+ route: "router",
1557
+ page: "router",
1558
+ index: "router",
1559
+ group: "router",
1560
+ fallback: "router",
1561
+ lazy: "router",
1562
+ createRouteRegistry: "router",
1563
+ resource: "resources",
1564
+ task: "resources",
1565
+ timer: "resources",
1566
+ stream: "resources",
1567
+ on: "resources",
1568
+ onRouteChange: "router",
1569
+ renderToString: "ssr",
1570
+ renderToStream: "ssr",
1571
+ createStaticGen: "ssg"
1572
+ };
1573
+ const importSubpathRule = {
1574
+ id: "askr/import-subpath",
1575
+ category: "configuration",
1576
+ severity: "error",
1577
+ description: "Askr APIs must be imported from their public owning subpath.",
1578
+ analyze(context) {
1579
+ const diagnostics = [];
1580
+ for (const sourceFile of context.sourceFiles) for (const statement of sourceFile.statements) {
1581
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/askr" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1582
+ const elements = statement.importClause.namedBindings.elements;
1583
+ const misplaced = elements.filter((element) => IMPORT_SUBPATHS[element.propertyName?.text ?? element.name.text]);
1584
+ if (misplaced.length === 0) continue;
1585
+ const valid = elements.filter((element) => !misplaced.includes(element));
1586
+ const groups = /* @__PURE__ */ new Map();
1587
+ for (const element of misplaced) {
1588
+ const imported = element.propertyName?.text ?? element.name.text;
1589
+ const subpath = IMPORT_SUBPATHS[imported];
1590
+ const list = groups.get(subpath) ?? [];
1591
+ list.push(element);
1592
+ groups.set(subpath, list);
1593
+ }
1594
+ const clauseType = statement.importClause.isTypeOnly ? "type " : "";
1595
+ const lines = [];
1596
+ if (valid.length > 0) lines.push(`import ${clauseType}{ ${valid.map((entry) => entry.getText(sourceFile)).join(", ")} } from "@askrjs/askr";`);
1597
+ for (const [subpath, entries] of [...groups].sort(([left], [right]) => left.localeCompare(right))) lines.push(`import ${clauseType}{ ${entries.map((entry) => entry.getText(sourceFile)).join(", ")} } from "@askrjs/askr/${subpath}";`);
1598
+ diagnostics.push(diagnostic(context, misplaced[0], this, `Root import contains ${misplaced.length} API specifier${misplaced.length === 1 ? "" : "s"} owned by public subpaths.`, "Import each API from its documented public subpath.", {
1599
+ description: "Split misplaced Askr root imports by public subpath",
1600
+ filePath: sourceFile.fileName,
1601
+ start: statement.getStart(sourceFile),
1602
+ end: statement.getEnd(),
1603
+ replacement: lines.join("\n")
1604
+ }));
1605
+ }
1606
+ return diagnostics;
1607
+ }
1608
+ };
1609
+ function literalJsxString(attribute) {
1610
+ if (!attribute || !ts.isJsxAttribute(attribute) || !attribute.initializer) return null;
1611
+ if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
1612
+ const expression = jsxExpression(attribute);
1613
+ return expression && ts.isStringLiteralLike(expression) ? expression.text : null;
1614
+ }
1615
+ const linkContractRule = {
1616
+ id: "askr/link-contract",
1617
+ category: "correctness",
1618
+ severity: "error",
1619
+ description: "Link destinations must be unambiguous and use runtime-safe schemes.",
1620
+ analyze(context) {
1621
+ const diagnostics = [];
1622
+ const unsafe = /^(?:javascript|data|vbscript):/i;
1623
+ for (const sourceFile of context.sourceFiles) {
1624
+ const bindings = sourceBindings(sourceFile);
1625
+ if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "Link")) continue;
1626
+ visit(sourceFile, (node) => {
1627
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
1628
+ if (canonicalJsxName(node.tagName, bindings) !== "Link") return;
1629
+ const attributes = jsxAttributes(node);
1630
+ if ([...attributes.values()].some(ts.isJsxSpreadAttribute)) return;
1631
+ const to = attributes.get("to");
1632
+ const href = attributes.get("href");
1633
+ if (!to && !href) diagnostics.push(diagnostic(context, node.tagName, this, "<Link> requires a to or href destination."));
1634
+ else if (to && href) diagnostics.push(diagnostic(context, node.tagName, this, "<Link> cannot specify both to and href."));
1635
+ const destination = literalJsxString(href ?? to);
1636
+ if (destination && unsafe.test(destination.trim())) diagnostics.push(diagnostic(context, href ?? to ?? node.tagName, this, `<Link> uses the unsafe '${destination.split(":")[0]}:' URL scheme.`, "Use http, https, mailto, tel, sms, a relative URL, or a route reference."));
1637
+ });
1638
+ }
1639
+ return diagnostics;
1640
+ }
1641
+ };
1642
+ function packageName(manifest) {
1643
+ return typeof manifest.name === "string" ? manifest.name : "";
1644
+ }
1645
+ const ANALYZE_RULES = [
1646
+ parseErrorRule,
1647
+ stableControlBoundaryRule,
1768
1648
  stableRenderRule,
1769
- renderSideEffectRule,
1649
+ renderScopeRequiredRule,
1650
+ exhaustiveDependenciesRule,
1651
+ forRowClosureCaptureRule,
1652
+ stableModuleIdentityRule,
1653
+ queryKeyContractRule,
1770
1654
  stateAccessRule,
1771
1655
  stateRenderWriteRule,
1772
1656
  resourceCancellationRule,
@@ -1774,6 +1658,7 @@ const ANALYZE_RULES = [
1774
1658
  lifecycleContractRule,
1775
1659
  streamContractRule,
1776
1660
  dataContractRule,
1661
+ linkContractRule,
1777
1662
  invalidationContractRule,
1778
1663
  forContractRule,
1779
1664
  controlContractRule,
@@ -1782,6 +1667,7 @@ const ANALYZE_RULES = [
1782
1667
  asyncComponentRule,
1783
1668
  routeRegistryRule,
1784
1669
  routePathRule,
1670
+ routeScopeStructureRule,
1785
1671
  dataCancellationRule,
1786
1672
  bootRegistryRule,
1787
1673
  islandContractRule,
@@ -1789,55 +1675,90 @@ const ANALYZE_RULES = [
1789
1675
  actionContractRule,
1790
1676
  actionPromiseRule,
1791
1677
  renderAllocationRule,
1792
- ssrGlobalsRule,
1793
1678
  {
1794
- id: "askr/framework-config",
1795
- category: "configuration",
1796
- severity: "error",
1797
- description: "TypeScript and Vite must use Askr's JSX/runtime wiring.",
1679
+ id: "askr/no-hardcoded-theme-token",
1680
+ category: "correctness",
1681
+ severity: "warning",
1682
+ description: "Runtime UI literals should use semantic theme tokens.",
1798
1683
  analyze(context) {
1684
+ if (["@askrjs/askr", "@askrjs/themes"].includes(packageName(context.workspace.manifest))) return [];
1799
1685
  const diagnostics = [];
1800
- const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx"));
1801
- if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics;
1802
- const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json");
1803
- const tsconfigSource = ts.sys.readFile(tsconfigPath);
1804
- if (context.program.getCompilerOptions().jsxImportSource !== "@askrjs/askr") {
1805
- let fix;
1806
- if (tsconfigSource) try {
1807
- const parsed = JSON.parse(tsconfigSource);
1808
- parsed.compilerOptions = {
1809
- ...parsed.compilerOptions && typeof parsed.compilerOptions === "object" && !Array.isArray(parsed.compilerOptions) ? parsed.compilerOptions : {},
1810
- jsx: "react-jsx",
1811
- jsxImportSource: "@askrjs/askr"
1812
- };
1813
- fix = {
1814
- description: "Configure TypeScript to use the Askr JSX runtime",
1815
- filePath: tsconfigPath,
1816
- start: 0,
1817
- end: tsconfigSource.length,
1818
- replacement: `${JSON.stringify(parsed, null, 2)}\n`
1819
- };
1820
- } catch {}
1821
- diagnostics.push(diagnostic(context, tsx, this, "TSX is present but compilerOptions.jsxImportSource is not '@askrjs/askr'.", "Set jsx to react-jsx and jsxImportSource to @askrjs/askr.", fix));
1686
+ const color = /(?:#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\()/i;
1687
+ for (const sourceFile of context.sourceFiles) {
1688
+ if (/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName)) continue;
1689
+ if (!color.test(sourceFile.text)) continue;
1690
+ visit(sourceFile, (node) => {
1691
+ if ((ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) && color.test(node.text)) diagnostics.push(diagnostic(context, node, this, `Runtime UI literal '${node.text}' hardcodes a color instead of a theme token.`, "Use a semantic prop, theme variable, or design-system class."));
1692
+ });
1822
1693
  }
1823
- const viteConfig = context.sourceFiles.find((sourceFile) => /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")));
1824
- if (viteConfig) {
1825
- let pluginLocalName = null;
1826
- for (const statement of viteConfig.statements) {
1827
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/vite" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1828
- pluginLocalName = statement.importClause.namedBindings.elements.find((element) => (element.propertyName?.text ?? element.name.text) === "askr")?.name.text ?? null;
1694
+ return diagnostics;
1695
+ }
1696
+ },
1697
+ {
1698
+ id: "askr/no-effect-data-loading",
1699
+ category: "correctness",
1700
+ severity: "warning",
1701
+ description: "Fetch-to-state data loading should use resource rather than task effects.",
1702
+ analyze(context) {
1703
+ const diagnostics = [];
1704
+ for (const sourceFile of context.sourceFiles) {
1705
+ const bindings = sourceBindings(sourceFile);
1706
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "task")) continue;
1707
+ const state = collectStateBindings(sourceFile, bindings);
1708
+ for (const { node, name } of sourceFacts(sourceFile).calls) {
1709
+ if (name !== "task") continue;
1710
+ const callback = node.arguments[0];
1711
+ if (!callback || !ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback) || !/\bfetch\s*\(/.test(callback.body.getText())) continue;
1712
+ let writesState = false;
1713
+ const walk = (candidate) => {
1714
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && state.setters.has(candidate.expression.text)) writesState = true;
1715
+ ts.forEachChild(candidate, walk);
1716
+ };
1717
+ walk(callback.body);
1718
+ if (!writesState) continue;
1719
+ diagnostics.push(diagnostic(context, callback, this, "task() fetches data and writes it into same-component state.", "Use resource() so cancellation, dependencies, loading, and errors are lifecycle-owned."));
1829
1720
  }
1830
- let pluginCalled = false;
1831
- if (pluginLocalName) visit(viteConfig, (node) => {
1832
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === pluginLocalName) pluginCalled = true;
1721
+ }
1722
+ return diagnostics;
1723
+ }
1724
+ },
1725
+ {
1726
+ id: "askr/testing-contract",
1727
+ category: "correctness",
1728
+ severity: "error",
1729
+ description: "Canonical test dispatches must be synchronously flushed before assertions.",
1730
+ analyze(context) {
1731
+ const diagnostics = [];
1732
+ for (const sourceFile of context.sourceFiles) {
1733
+ let canonicalDispatch = false;
1734
+ for (const statement of sourceFile.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text.endsWith("@askrjs/askr/testing")) canonicalDispatch = statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) ? statement.importClause.namedBindings.elements.some((element) => (element.propertyName?.text ?? element.name.text) === "dispatch") : false;
1735
+ if (!canonicalDispatch) continue;
1736
+ visit(sourceFile, (node) => {
1737
+ if (!ts.isBlock(node)) return;
1738
+ let pending = null;
1739
+ for (const statement of node.statements) {
1740
+ const text = statement.getText();
1741
+ let dispatch = null;
1742
+ const findDispatch = (candidate) => {
1743
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && candidate.expression.text === "dispatch") dispatch = candidate;
1744
+ ts.forEachChild(candidate, findDispatch);
1745
+ };
1746
+ findDispatch(statement);
1747
+ if (dispatch) pending = dispatch;
1748
+ if (pending && /(?:^|\.)(?:flush)\s*\(/.test(text)) pending = null;
1749
+ if (pending && /\b(?:expect|assert)\s*\(/.test(text)) {
1750
+ diagnostics.push(diagnostic(context, pending, this, "dispatch() reaches an assertion without a synchronous flush().", "Call flush() or result.flush() before the next assertion."));
1751
+ pending = null;
1752
+ }
1753
+ }
1833
1754
  });
1834
- const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite");
1835
- if (dependencyPresent && pluginCalled) return diagnostics;
1836
- diagnostics.push(diagnostic(context, viteConfig, this, !dependencyPresent ? "This Askr Vite project does not declare @askrjs/vite." : "Vite is configured without calling the @askrjs/vite askr() plugin.", "Declare @askrjs/vite, import askr, and include askr() in the plugin list."));
1837
1755
  }
1838
1756
  return diagnostics;
1839
1757
  }
1840
- }
1758
+ },
1759
+ ssrGlobalsRule,
1760
+ frameworkConfigRule,
1761
+ importSubpathRule
1841
1762
  ];
1842
1763
  function configuredSeverity(rule, configuration) {
1843
1764
  return configuration.rules[rule.id] ?? rule.severity;