@askrjs/cli 0.0.16 → 0.0.18

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.
@@ -168,7 +168,8 @@ const ASKR_CONCEPTS = {
168
168
  "task",
169
169
  "timer",
170
170
  "stream",
171
- "on"
171
+ "on",
172
+ "onRouteChange"
172
173
  ],
173
174
  data: [
174
175
  "createQuery",
@@ -335,6 +336,10 @@ function visit(sourceFile, callback) {
335
336
  };
336
337
  walk(sourceFile);
337
338
  }
339
+ function runtimeLiteralText(node) {
340
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return node.text;
341
+ return null;
342
+ }
338
343
  function containingFunction(node) {
339
344
  for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current)) return current;
340
345
  return null;
@@ -565,6 +570,12 @@ const controlContractRule = {
565
570
  const parentOpening = ts.isJsxElement(parentElement) ? parentElement.openingElement : null;
566
571
  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."));
567
572
  }
573
+ if (name === "Case" && ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent)) for (const child of node.parent.children) {
574
+ if (ts.isJsxText(child) && child.text.trim() === "") continue;
575
+ if (ts.isJsxExpression(child) && (!child.expression || child.expression.kind === ts.SyntaxKind.NullKeyword || child.expression.kind === ts.SyntaxKind.FalseKeyword)) continue;
576
+ if (ts.isJsxElement(child) && canonicalJsxName(child.openingElement.tagName, bindings) === "Match") continue;
577
+ 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."));
578
+ }
568
579
  });
569
580
  }
570
581
  return diagnostics;
@@ -644,6 +655,89 @@ function resolvedFunction(expression, context) {
644
655
  if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer;
645
656
  return null;
646
657
  }
658
+ function routeDefinitionCallback(call, name, context) {
659
+ return resolvedFunction(name === "group" ? call.arguments[1] : call.arguments.at(-1), context);
660
+ }
661
+ function normalizeRoutePrefix(pathname) {
662
+ if (!pathname.startsWith("/")) return null;
663
+ if (pathname === "/") return pathname;
664
+ return pathname.replace(/\/+$/, "") || "/";
665
+ }
666
+ function analyzedPagePrefix(pathname) {
667
+ if (!pathname) return null;
668
+ return normalizeRoutePrefix(pathname.startsWith("/") ? pathname : `/${pathname}`);
669
+ }
670
+ function joinAnalyzedRoutePath(prefix, pathname) {
671
+ const child = pathname.replace(/^\/+|\/+$/g, "");
672
+ if (!child) return prefix;
673
+ return prefix === "/" ? `/${child}` : `${prefix}/${child}`;
674
+ }
675
+ function analyzedRoutePath(call, name, scope) {
676
+ if (name !== "route" && name !== "page") return null;
677
+ const literal = call.arguments[0];
678
+ if (!literal || !ts.isStringLiteral(literal)) return null;
679
+ if (name === "page") return scope.page ? null : analyzedPagePrefix(literal.text);
680
+ if (literal.text.startsWith("/")) return scope.page ? null : normalizeRoutePrefix(literal.text);
681
+ return scope.page?.pathPrefix ? joinAnalyzedRoutePath(scope.page.pathPrefix, literal.text) : null;
682
+ }
683
+ const ROUTE_DEFINITION_CALLS = /* @__PURE__ */ new Set([
684
+ "route",
685
+ "page",
686
+ "index",
687
+ "group",
688
+ "fallback"
689
+ ]);
690
+ function walkRouteDefinition(context, definition, scope, visitor, active = /* @__PURE__ */ new Set()) {
691
+ if (active.has(definition)) return;
692
+ active.add(definition);
693
+ const bindings = sourceBindings(definition.getSourceFile());
694
+ const body = ts.isArrowFunction(definition) || ts.isFunctionExpression(definition) || ts.isFunctionDeclaration(definition) ? definition.body : void 0;
695
+ if (body) {
696
+ const walkNode = (node) => {
697
+ if (node !== body && ts.isFunctionLike(node)) return;
698
+ if (ts.isCallExpression(node)) {
699
+ const name = canonicalCallName(node.expression, bindings);
700
+ if (name && ROUTE_DEFINITION_CALLS.has(name)) {
701
+ visitor(node, name, scope);
702
+ if (name === "group") {
703
+ const callback = routeDefinitionCallback(node, "group", context);
704
+ if (callback) walkRouteDefinition(context, callback, scope, visitor, active);
705
+ } else if (name === "page" && !scope.page) {
706
+ const callback = routeDefinitionCallback(node, "page", context);
707
+ const pathname = node.arguments[0];
708
+ const pathPrefix = pathname && ts.isStringLiteral(pathname) ? analyzedPagePrefix(pathname.text) : null;
709
+ if (callback) walkRouteDefinition(context, callback, { page: {
710
+ pathPrefix,
711
+ indexCount: 0
712
+ } }, visitor, active);
713
+ }
714
+ return;
715
+ }
716
+ const called = resolvedFunction(node.expression, context);
717
+ if (called) {
718
+ walkRouteDefinition(context, called, scope, visitor, active);
719
+ return;
720
+ }
721
+ }
722
+ ts.forEachChild(node, walkNode);
723
+ };
724
+ walkNode(body);
725
+ }
726
+ active.delete(definition);
727
+ }
728
+ function walkRouteDefinitions(context, visitor) {
729
+ const entrypoints = [];
730
+ for (const sourceFile of context.sourceFiles) {
731
+ const bindings = sourceBindings(sourceFile);
732
+ visit(sourceFile, (node) => {
733
+ if (ts.isCallExpression(node) && canonicalCallName(node.expression, bindings) === "createRouteRegistry") {
734
+ const definition = resolvedFunction(node.arguments[0], context);
735
+ if (definition) entrypoints.push(definition);
736
+ }
737
+ });
738
+ }
739
+ for (const definition of entrypoints) walkRouteDefinition(context, definition, { page: null }, visitor);
740
+ }
647
741
  const asyncComponentRule = {
648
742
  id: "askr/no-async-component",
649
743
  category: "correctness",
@@ -737,27 +831,99 @@ const routePathRule = {
737
831
  id: "askr/route-path-syntax",
738
832
  category: "correctness",
739
833
  severity: "error",
740
- description: "Askr route parameters use {name} segments.",
834
+ description: "Static route paths must satisfy the runtime authoring contract.",
741
835
  analyze(context) {
742
836
  const diagnostics = [];
743
- const pathCalls = /* @__PURE__ */ new Set(["route", "page"]);
744
- for (const sourceFile of context.sourceFiles) {
745
- const bindings = sourceBindings(sourceFile);
746
- visit(sourceFile, (node) => {
747
- if (!ts.isCallExpression(node)) return;
748
- const name = canonicalCallName(node.expression, bindings);
749
- const first = node.arguments[0];
750
- if (!name || !pathCalls.has(name) || !first || !ts.isStringLiteral(first)) return;
751
- if (!/:([^/{}]+)/.test(first.text)) return;
752
- const replacement = first.text.replace(/:([^/{}]+)/g, "{$1}");
753
- diagnostics.push(diagnostic(context, first, this, `Route path '${first.text}' uses colon parameters instead of {name} segments.`, `Use '${replacement}'.`, {
837
+ const seen = /* @__PURE__ */ new Set();
838
+ const inspect = (call, name, scope) => {
839
+ if (name !== "route" && name !== "page") return;
840
+ const literal = call.arguments[0];
841
+ if (!literal || !ts.isStringLiteral(literal)) return;
842
+ const sourceFile = literal.getSourceFile();
843
+ const key = `${sourceFile.fileName}:${literal.getStart(sourceFile)}`;
844
+ if (seen.has(key)) return;
845
+ seen.add(key);
846
+ if (name === "page" && literal.text.length === 0) {
847
+ diagnostics.push(diagnostic(context, literal, this, "page() requires a non-empty path.", "Pass a non-empty path to page()."));
848
+ return;
849
+ }
850
+ if (scope.page && name === "page") return;
851
+ if (scope.page && literal.text.startsWith("/")) return;
852
+ let pathname = literal.text;
853
+ if (!pathname.startsWith("/")) {
854
+ if (name === "route" && !scope.page) {
855
+ const replacement = `/${pathname}`;
856
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${pathname}' must begin with "/" outside a page scope.`, `Use '${replacement}'.`, {
857
+ description: "Add the required leading slash to the root route path",
858
+ filePath: sourceFile.fileName,
859
+ start: literal.getStart(sourceFile),
860
+ end: literal.getEnd(),
861
+ replacement: JSON.stringify(replacement)
862
+ }));
863
+ return;
864
+ }
865
+ pathname = scope.page?.pathPrefix ? joinAnalyzedRoutePath(scope.page.pathPrefix, pathname) : `/${pathname.replace(/^\/+/, "")}`;
866
+ }
867
+ if (/\/{2,}/.test(pathname)) {
868
+ const replacement = literal.text.replace(/\/{2,}/g, "/");
869
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${literal.text}' contains consecutive slashes.`, `Use '${replacement}'.`, {
870
+ description: "Collapse consecutive slashes in the route path",
871
+ filePath: sourceFile.fileName,
872
+ start: literal.getStart(sourceFile),
873
+ end: literal.getEnd(),
874
+ replacement: JSON.stringify(replacement)
875
+ }));
876
+ return;
877
+ }
878
+ if (/:([^/{}]+)/.test(pathname)) {
879
+ const replacement = literal.text.replace(/:([^/{}]+)/g, "{$1}");
880
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${literal.text}' uses colon parameters instead of {name} segments.`, `Use '${replacement}'.`, {
754
881
  description: "Convert colon route parameters to Askr {name} segments",
755
882
  filePath: sourceFile.fileName,
756
- start: first.getStart(sourceFile),
757
- end: first.getEnd(),
883
+ start: literal.getStart(sourceFile),
884
+ end: literal.getEnd(),
758
885
  replacement: JSON.stringify(replacement)
759
886
  }));
760
- });
887
+ return;
888
+ }
889
+ const segments = pathname.split("/").filter(Boolean);
890
+ const seenParameters = /* @__PURE__ */ new Set();
891
+ for (let index = 0; index < segments.length; index += 1) {
892
+ const segment = segments[index];
893
+ if (segment === "*") continue;
894
+ const hasOpenBrace = segment.includes("{");
895
+ const hasCloseBrace = segment.includes("}");
896
+ if (!hasOpenBrace && !hasCloseBrace) continue;
897
+ if (!(segment.startsWith("{") && segment.endsWith("}"))) {
898
+ diagnostics.push(diagnostic(context, literal, this, "Route parameter segments must use complete {name} interpolation.", "Make the entire path segment a {name} or {*name} interpolation."));
899
+ return;
900
+ }
901
+ const rawParameter = segment.slice(1, -1).trim();
902
+ const splat = rawParameter.startsWith("*");
903
+ const parameter = (splat ? rawParameter.slice(1) : rawParameter).trim();
904
+ if (!parameter) {
905
+ diagnostics.push(diagnostic(context, literal, this, splat ? "Route splat parameter name cannot be empty." : "Route parameter name cannot be empty.", "Give every route parameter a non-empty name."));
906
+ return;
907
+ }
908
+ if (splat && parameter === "*") {
909
+ diagnostics.push(diagnostic(context, literal, this, "Route named splat parameter name cannot be \"*\".", "Use a descriptive named splat such as {*path}."));
910
+ return;
911
+ }
912
+ if (splat && index !== segments.length - 1) {
913
+ diagnostics.push(diagnostic(context, literal, this, "Route named splat parameters must be the final segment.", "Move the named splat to the end of the route path."));
914
+ return;
915
+ }
916
+ if (seenParameters.has(parameter)) {
917
+ diagnostics.push(diagnostic(context, literal, this, `Route path cannot reuse duplicate parameter name "${parameter}".`, "Use a unique name for each route parameter."));
918
+ return;
919
+ }
920
+ seenParameters.add(parameter);
921
+ }
922
+ };
923
+ walkRouteDefinitions(context, inspect);
924
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
925
+ if (name !== "route" && name !== "page") continue;
926
+ inspect(node, name, { page: null });
761
927
  }
762
928
  return diagnostics;
763
929
  }
@@ -926,6 +1092,7 @@ const dataContractRule = {
926
1092
  const key = optionExpression(options, "key");
927
1093
  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."));
928
1094
  else if (ts.isLiteralExpression(key) && !ts.isStringLiteralLike(key)) diagnostics.push(diagnostic(context, key, this, "createQuery() key must be a string or key function."));
1095
+ 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."));
929
1096
  const fetcher = optionExpression(options, "fetch");
930
1097
  if (!fetcher || isProvablyNonFunction(fetcher)) diagnostics.push(diagnostic(context, fetcher ?? options, this, "createQuery() requires a fetch function.", "Pass a cancellable fetch function."));
931
1098
  } else {
@@ -1201,31 +1368,589 @@ function hasDependency(manifest, packageName) {
1201
1368
  "optionalDependencies"
1202
1369
  ].some((section) => packageName in dependencyRecord(manifest, section));
1203
1370
  }
1204
- const ANALYZE_RULES = [
1205
- {
1206
- id: "askr/parse-error",
1207
- category: "correctness",
1208
- severity: "error",
1209
- description: "Source must parse before framework analysis is reliable.",
1210
- analyze(context) {
1211
- return context.program.getSyntacticDiagnostics().filter((entry) => Boolean(entry.file && context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName))).map((entry) => {
1212
- const start = entry.start ?? 0;
1213
- const point = entry.file.getLineAndCharacterOfPosition(start);
1214
- return {
1215
- ruleId: this.id,
1216
- category: this.category,
1217
- severity: this.severity,
1218
- message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"),
1219
- workspace: context.workspace.name,
1220
- file: workspaceRelativeFile(context, entry.file.fileName),
1221
- line: point.line + 1,
1222
- column: point.character + 1,
1223
- remediation: "Fix the syntax error so framework rules can inspect this file reliably."
1371
+ const frameworkConfigRule = {
1372
+ id: "askr/framework-config",
1373
+ category: "configuration",
1374
+ severity: "error",
1375
+ description: "TypeScript and Vite must use Askr's JSX/runtime wiring.",
1376
+ analyze(context) {
1377
+ const diagnostics = [];
1378
+ const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx"));
1379
+ if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics;
1380
+ const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json");
1381
+ const tsconfigSource = ts.sys.readFile(tsconfigPath);
1382
+ if (context.program.getCompilerOptions().jsxImportSource !== "@askrjs/askr") {
1383
+ let fix;
1384
+ if (tsconfigSource) try {
1385
+ const parsed = JSON.parse(tsconfigSource);
1386
+ parsed.compilerOptions = {
1387
+ ...parsed.compilerOptions && typeof parsed.compilerOptions === "object" && !Array.isArray(parsed.compilerOptions) ? parsed.compilerOptions : {},
1388
+ jsx: "react-jsx",
1389
+ jsxImportSource: "@askrjs/askr"
1224
1390
  };
1391
+ fix = {
1392
+ description: "Configure TypeScript to use the Askr JSX runtime",
1393
+ filePath: tsconfigPath,
1394
+ start: 0,
1395
+ end: tsconfigSource.length,
1396
+ replacement: `${JSON.stringify(parsed, null, 2)}\n`
1397
+ };
1398
+ } catch {}
1399
+ 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));
1400
+ }
1401
+ const viteConfig = context.sourceFiles.find((sourceFile) => /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")));
1402
+ if (viteConfig) {
1403
+ let pluginLocalName = null;
1404
+ for (const statement of viteConfig.statements) {
1405
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/vite" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1406
+ pluginLocalName = statement.importClause.namedBindings.elements.find((element) => (element.propertyName?.text ?? element.name.text) === "askr")?.name.text ?? null;
1407
+ }
1408
+ let pluginCalled = false;
1409
+ if (pluginLocalName) visit(viteConfig, (node) => {
1410
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === pluginLocalName) pluginCalled = true;
1225
1411
  });
1412
+ const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite");
1413
+ if (dependencyPresent && pluginCalled) return diagnostics;
1414
+ 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."));
1226
1415
  }
1227
- },
1416
+ return diagnostics;
1417
+ }
1418
+ };
1419
+ const parseErrorRule = {
1420
+ id: "askr/parse-error",
1421
+ category: "correctness",
1422
+ severity: "error",
1423
+ description: "Source must parse before framework analysis is reliable.",
1424
+ analyze(context) {
1425
+ return context.program.getSyntacticDiagnostics().filter((entry) => Boolean(entry.file && context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName))).map((entry) => {
1426
+ const start = entry.start ?? 0;
1427
+ const point = entry.file.getLineAndCharacterOfPosition(start);
1428
+ return {
1429
+ ruleId: this.id,
1430
+ category: this.category,
1431
+ severity: this.severity,
1432
+ message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"),
1433
+ workspace: context.workspace.name,
1434
+ file: workspaceRelativeFile(context, entry.file.fileName),
1435
+ line: point.line + 1,
1436
+ column: point.character + 1,
1437
+ remediation: "Fix the syntax error so framework rules can inspect this file reliably."
1438
+ };
1439
+ });
1440
+ }
1441
+ };
1442
+ function nearestComponent(node) {
1443
+ for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current)) {
1444
+ const name = functionName(current);
1445
+ if (name && /^[A-Z]/.test(name) || containsJsx(current)) return current;
1446
+ }
1447
+ return null;
1448
+ }
1449
+ function isInsideNestedFunction(node, owner) {
1450
+ for (let current = node.parent; current && current !== owner; current = current.parent) if (ts.isFunctionLike(current)) return true;
1451
+ return false;
1452
+ }
1453
+ function jsxExpression(attribute) {
1454
+ if (!attribute || !ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer)) return null;
1455
+ return attribute.initializer.expression ?? null;
1456
+ }
1457
+ const stableControlBoundaryRule = {
1458
+ id: "askr/stable-control-boundary",
1459
+ category: "correctness",
1460
+ severity: "error",
1461
+ description: "Conditional control boundaries must not change identity between renders.",
1462
+ analyze(context) {
1463
+ const diagnostics = [];
1464
+ for (const sourceFile of context.sourceFiles) {
1465
+ const bindings = sourceBindings(sourceFile);
1466
+ const facts = sourceFacts(sourceFile);
1467
+ if (!facts.jsx.some((fact) => [
1468
+ "For",
1469
+ "Show",
1470
+ "Case"
1471
+ ].includes(fact.name)) && !facts.calls.some((fact) => fact.name === "defineScope")) continue;
1472
+ visit(sourceFile, (node) => {
1473
+ let candidate = null;
1474
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && [
1475
+ "For",
1476
+ "Show",
1477
+ "Case"
1478
+ ].includes(canonicalJsxName(node.tagName, bindings) ?? "")) candidate = node;
1479
+ else if (ts.isCallExpression(node) && canonicalCallName(node.expression, bindings) === "defineScope") candidate = node;
1480
+ if (!candidate) return;
1481
+ const owner = nearestComponent(candidate);
1482
+ if (!owner || !isControlFlowAncestor(candidate, owner)) return;
1483
+ 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."));
1484
+ });
1485
+ }
1486
+ return diagnostics;
1487
+ }
1488
+ };
1489
+ function dependencyNames(array) {
1490
+ const names = /* @__PURE__ */ new Set();
1491
+ for (const element of array.elements) {
1492
+ if (ts.isIdentifier(element)) names.add(element.text);
1493
+ if (ts.isCallExpression(element) && ts.isIdentifier(element.expression)) names.add(element.expression.text);
1494
+ }
1495
+ return names;
1496
+ }
1497
+ const exhaustiveDependenciesRule = {
1498
+ id: "askr/exhaustive-dependencies",
1499
+ category: "correctness",
1500
+ severity: "warning",
1501
+ description: "Resource and stream dependency arrays must list directly read reactive values.",
1502
+ analyze(context) {
1503
+ const diagnostics = [];
1504
+ for (const sourceFile of context.sourceFiles) {
1505
+ const bindings = sourceBindings(sourceFile);
1506
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "resource" || fact.name === "stream")) continue;
1507
+ const reactive = collectStateBindings(sourceFile, bindings).getters;
1508
+ for (const { node, name } of sourceFacts(sourceFile).calls) {
1509
+ if (name !== "resource" && name !== "stream") continue;
1510
+ const loader = node.arguments[0];
1511
+ const deps = name === "resource" ? node.arguments[1] : node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1]) ? optionExpression(node.arguments[1], "deps") : node.arguments[1];
1512
+ if (!loader || !ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader) || !deps || !ts.isArrayLiteralExpression(deps) || deps.elements.some(ts.isSpreadElement)) continue;
1513
+ const declared = dependencyNames(deps);
1514
+ const missing = /* @__PURE__ */ new Set();
1515
+ const walk = (candidate) => {
1516
+ if (candidate !== loader && ts.isFunctionLike(candidate)) return;
1517
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && reactive.has(candidate.expression.text) && !declared.has(candidate.expression.text)) missing.add(candidate.expression.text);
1518
+ ts.forEachChild(candidate, walk);
1519
+ };
1520
+ walk(loader.body);
1521
+ 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.`));
1522
+ }
1523
+ }
1524
+ return diagnostics;
1525
+ }
1526
+ };
1527
+ const forRowClosureCaptureRule = {
1528
+ id: "askr/for-row-closure-capture",
1529
+ category: "correctness",
1530
+ severity: "warning",
1531
+ description: "For row renderers must not capture changing component reactive values.",
1532
+ analyze(context) {
1533
+ const diagnostics = [];
1534
+ for (const sourceFile of context.sourceFiles) {
1535
+ const bindings = sourceBindings(sourceFile);
1536
+ if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "For")) continue;
1537
+ const reactive = collectStateBindings(sourceFile, bindings).getters;
1538
+ const snapshots = /* @__PURE__ */ new Set();
1539
+ visit(sourceFile, (candidate) => {
1540
+ 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);
1541
+ });
1542
+ visit(sourceFile, (node) => {
1543
+ if (!ts.isJsxElement(node) || canonicalJsxName(node.openingElement.tagName, bindings) !== "For") return;
1544
+ for (const child of node.children) {
1545
+ if (!ts.isJsxExpression(child) || !child.expression || !ts.isArrowFunction(child.expression) && !ts.isFunctionExpression(child.expression)) continue;
1546
+ const renderer = child.expression;
1547
+ const captured = /* @__PURE__ */ new Set();
1548
+ const walk = (candidate) => {
1549
+ for (let current = candidate.parent; current && current !== renderer; current = current.parent) if (ts.isJsxAttribute(current)) return;
1550
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && reactive.has(candidate.expression.text)) captured.add(candidate.expression.text);
1551
+ if (ts.isIdentifier(candidate) && snapshots.has(candidate.text) && !(ts.isPropertyAccessExpression(candidate.parent) && candidate.parent.name === candidate)) captured.add(candidate.text);
1552
+ ts.forEachChild(candidate, walk);
1553
+ };
1554
+ walk(renderer.body);
1555
+ 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."));
1556
+ }
1557
+ });
1558
+ }
1559
+ return diagnostics;
1560
+ }
1561
+ };
1562
+ const RENDER_REQUIRED_CONCEPTS = /* @__PURE__ */ new Set([
1563
+ "readScope",
1564
+ "getSignal",
1565
+ "routeData",
1566
+ "ErrorBoundary"
1567
+ ]);
1568
+ const NON_RENDER_SCHEDULERS = /* @__PURE__ */ new Set([
1569
+ "setTimeout",
1570
+ "setInterval",
1571
+ "queueMicrotask",
1572
+ "requestAnimationFrame"
1573
+ ]);
1574
+ const PROMISE_CONTINUATIONS = /* @__PURE__ */ new Set([
1575
+ "then",
1576
+ "catch",
1577
+ "finally"
1578
+ ]);
1579
+ function nonRenderCallbackOwner(callback, bindings) {
1580
+ const parent = callback.parent;
1581
+ if (ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent)) {
1582
+ const attribute = parent.parent;
1583
+ return ts.isIdentifier(attribute.name) && /^on[A-Z]/.test(attribute.name.text);
1584
+ }
1585
+ if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === callback)) {
1586
+ if (canonicalCallName(parent.expression, bindings) === "task") return true;
1587
+ if (ts.isIdentifier(parent.expression)) return NON_RENDER_SCHEDULERS.has(parent.expression.text);
1588
+ if (ts.isPropertyAccessExpression(parent.expression)) return PROMISE_CONTINUATIONS.has(parent.expression.name.text);
1589
+ }
1590
+ if (ts.isPropertyAssignment(parent) && parent.name.getText() === "body") {
1591
+ const options = parent.parent;
1592
+ const call = options.parent;
1593
+ return ts.isObjectLiteralExpression(options) && ts.isCallExpression(call) && call.arguments.some((argument) => argument === options) && canonicalCallName(call.expression, bindings) === "task";
1594
+ }
1595
+ return false;
1596
+ }
1597
+ function isResourceLoader(callback, bindings) {
1598
+ const parent = callback.parent;
1599
+ return ts.isCallExpression(parent) && parent.arguments[0] === callback && canonicalCallName(parent.expression, bindings) === "resource";
1600
+ }
1601
+ const renderScopeRequiredRule = {
1602
+ id: "askr/render-scope-required",
1603
+ category: "correctness",
1604
+ severity: "error",
1605
+ description: "Render-scoped APIs cannot be created in callbacks that execute outside rendering.",
1606
+ analyze(context) {
1607
+ const diagnostics = [];
1608
+ for (const sourceFile of context.sourceFiles) {
1609
+ const bindings = sourceBindings(sourceFile);
1610
+ if (!sourceFacts(sourceFile).calls.some((fact) => RENDER_REQUIRED_CONCEPTS.has(fact.name) || RENDER_SCOPED_CONCEPTS.has(fact.name))) continue;
1611
+ visit(sourceFile, (node) => {
1612
+ if (!ts.isCallExpression(node)) return;
1613
+ const name = canonicalCallName(node.expression, bindings);
1614
+ if (!name || !RENDER_REQUIRED_CONCEPTS.has(name) && !RENDER_SCOPED_CONCEPTS.has(name)) return;
1615
+ const required = RENDER_REQUIRED_CONCEPTS.has(name);
1616
+ const owner = containingFunction(node);
1617
+ if (!owner) {
1618
+ if (!required) return;
1619
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() requires an active render scope and cannot be called at module scope.`, `Move ${name}() into component rendering.`));
1620
+ return;
1621
+ }
1622
+ for (let current = owner; current; current = current.parent) {
1623
+ if (!ts.isFunctionLike(current)) continue;
1624
+ if (nonRenderCallbackOwner(current, bindings)) {
1625
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is called in a callback that is statically outside component rendering.`, `Read ${name}() during component render and use its value from the callback.`));
1626
+ return;
1627
+ }
1628
+ if (name === "readScope" && isResourceLoader(current, bindings)) return;
1629
+ }
1630
+ });
1631
+ }
1632
+ return diagnostics;
1633
+ }
1634
+ };
1635
+ const stableModuleIdentityRule = {
1636
+ id: "askr/stable-module-identity",
1637
+ category: "correctness",
1638
+ severity: "error",
1639
+ description: "Lazy modules and scopes must have stable identity across renders.",
1640
+ analyze(context) {
1641
+ const diagnostics = [];
1642
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1643
+ if (name !== "lazy" && name !== "defineScope") continue;
1644
+ const owner = nearestComponent(node);
1645
+ if (!owner || isInsideNestedFunction(node, owner)) continue;
1646
+ 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.`));
1647
+ }
1648
+ return diagnostics;
1649
+ }
1650
+ };
1651
+ const queryKeyContractRule = {
1652
+ id: "askr/query-key-contract",
1653
+ category: "correctness",
1654
+ severity: "error",
1655
+ description: "Query keys and scopes must be deterministic and serializable.",
1656
+ analyze(context) {
1657
+ const diagnostics = [];
1658
+ const nondeterministic = /* @__PURE__ */ new Set([
1659
+ "random",
1660
+ "now",
1661
+ "randomUUID"
1662
+ ]);
1663
+ for (const sourceFile of context.sourceFiles) {
1664
+ const bindings = sourceBindings(sourceFile);
1665
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "createQuery" || fact.name === "queryScope")) continue;
1666
+ for (const call of sourceFacts(sourceFile).allCalls) {
1667
+ const name = canonicalCallName(call.expression, bindings);
1668
+ let expression;
1669
+ if (name === "createQuery") {
1670
+ const options = call.arguments[0];
1671
+ if (options && ts.isObjectLiteralExpression(options)) expression = optionExpression(options, "key");
1672
+ } else if (name === "queryScope") expression = call.arguments[0];
1673
+ else continue;
1674
+ if (!expression) continue;
1675
+ let invalid = null;
1676
+ const walk = (node) => {
1677
+ if (invalid) return;
1678
+ if (ts.isCallExpression(node) && (ts.isPropertyAccessExpression(node.expression) && nondeterministic.has(node.expression.name.text) || ts.isIdentifier(node.expression) && node.expression.text === "Symbol")) {
1679
+ invalid = node;
1680
+ return;
1681
+ }
1682
+ ts.forEachChild(node, walk);
1683
+ };
1684
+ walk(expression);
1685
+ if (!invalid) continue;
1686
+ 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."));
1687
+ }
1688
+ }
1689
+ return diagnostics;
1690
+ }
1691
+ };
1692
+ const routeScopeStructureRule = {
1693
+ id: "askr/route-scope-structure",
1694
+ category: "correctness",
1695
+ severity: "error",
1696
+ description: "Nested page route scopes must have one index and relative child routes.",
1697
+ analyze(context) {
1698
+ const diagnostics = [];
1699
+ const seen = /* @__PURE__ */ new Set();
1700
+ const visitedCalls = /* @__PURE__ */ new Set();
1701
+ const reportOnce = (node, kind, create) => {
1702
+ const sourceFile = node.getSourceFile();
1703
+ const key = `${sourceFile.fileName}:${node.getStart(sourceFile)}:${kind}`;
1704
+ if (seen.has(key)) return;
1705
+ seen.add(key);
1706
+ diagnostics.push(create());
1707
+ };
1708
+ const inspect = (call, name, scope) => {
1709
+ visitedCalls.add(call);
1710
+ if (!scope.page) return;
1711
+ if (name === "page") {
1712
+ reportOnce(call, "nested-page", () => diagnostic(context, call.expression, this, "page() cannot be nested inside another page().", "Use route() for child leaves or group() for inherited behavior in the page scope."));
1713
+ return;
1714
+ }
1715
+ if (name === "index") {
1716
+ scope.page.indexCount += 1;
1717
+ if (scope.page.indexCount > 1) reportOnce(call, "multiple-index", () => diagnostic(context, call.expression, this, "A page scope declares more than one index route.", "Keep exactly one index() declaration in a page scope."));
1718
+ return;
1719
+ }
1720
+ if (name !== "route") return;
1721
+ const routePath = call.arguments[0];
1722
+ if (!routePath || !ts.isStringLiteral(routePath) || !routePath.text.startsWith("/") || routePath.text.length <= 1) return;
1723
+ const sourceFile = routePath.getSourceFile();
1724
+ const replacement = routePath.text.replace(/^\/+/, "");
1725
+ reportOnce(routePath, "absolute-child", () => diagnostic(context, routePath, this, `Child route '${routePath.text}' is absolute inside a page scope.`, `Use the relative child path '${replacement}'.`, {
1726
+ description: "Strip the leading slash from a proven child route",
1727
+ filePath: sourceFile.fileName,
1728
+ start: routePath.getStart(sourceFile),
1729
+ end: routePath.getEnd(),
1730
+ replacement: JSON.stringify(replacement)
1731
+ }));
1732
+ };
1733
+ walkRouteDefinitions(context, inspect);
1734
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1735
+ if (name !== "page" || visitedCalls.has(node)) continue;
1736
+ const callback = routeDefinitionCallback(node, "page", context);
1737
+ if (!callback) continue;
1738
+ const literal = node.arguments[0];
1739
+ walkRouteDefinition(context, callback, { page: {
1740
+ pathPrefix: literal && ts.isStringLiteral(literal) ? analyzedPagePrefix(literal.text) : null,
1741
+ indexCount: 0
1742
+ } }, inspect);
1743
+ }
1744
+ return diagnostics;
1745
+ }
1746
+ };
1747
+ const IMPORT_SUBPATHS = {
1748
+ ActionForm: "actions",
1749
+ action: "actions",
1750
+ defineAction: "actions",
1751
+ createSPA: "boot",
1752
+ hydrateSPA: "boot",
1753
+ createIsland: "boot",
1754
+ createIslands: "boot",
1755
+ createQuery: "data",
1756
+ createMutation: "data",
1757
+ invalidate: "data",
1758
+ invalidateOnInterval: "data",
1759
+ queryScope: "data",
1760
+ route: "router",
1761
+ page: "router",
1762
+ index: "router",
1763
+ group: "router",
1764
+ fallback: "router",
1765
+ lazy: "router",
1766
+ createRouteRegistry: "router",
1767
+ resource: "resources",
1768
+ task: "resources",
1769
+ timer: "resources",
1770
+ stream: "resources",
1771
+ on: "resources",
1772
+ onRouteChange: "router",
1773
+ renderToString: "ssr",
1774
+ renderToStream: "ssr",
1775
+ createStaticGen: "ssg"
1776
+ };
1777
+ const importSubpathRule = {
1778
+ id: "askr/import-subpath",
1779
+ category: "configuration",
1780
+ severity: "error",
1781
+ description: "Askr APIs must be imported from their public owning subpath.",
1782
+ analyze(context) {
1783
+ const diagnostics = [];
1784
+ for (const sourceFile of context.sourceFiles) for (const statement of sourceFile.statements) {
1785
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/askr" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1786
+ const elements = statement.importClause.namedBindings.elements;
1787
+ const misplaced = elements.filter((element) => IMPORT_SUBPATHS[element.propertyName?.text ?? element.name.text]);
1788
+ if (misplaced.length === 0) continue;
1789
+ const valid = elements.filter((element) => !misplaced.includes(element));
1790
+ const groups = /* @__PURE__ */ new Map();
1791
+ for (const element of misplaced) {
1792
+ const imported = element.propertyName?.text ?? element.name.text;
1793
+ const subpath = IMPORT_SUBPATHS[imported];
1794
+ const list = groups.get(subpath) ?? [];
1795
+ list.push(element);
1796
+ groups.set(subpath, list);
1797
+ }
1798
+ const clauseType = statement.importClause.isTypeOnly ? "type " : "";
1799
+ const lines = [];
1800
+ if (valid.length > 0) lines.push(`import ${clauseType}{ ${valid.map((entry) => entry.getText(sourceFile)).join(", ")} } from "@askrjs/askr";`);
1801
+ 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}";`);
1802
+ 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.", {
1803
+ description: "Split misplaced Askr root imports by public subpath",
1804
+ filePath: sourceFile.fileName,
1805
+ start: statement.getStart(sourceFile),
1806
+ end: statement.getEnd(),
1807
+ replacement: lines.join("\n")
1808
+ }));
1809
+ }
1810
+ return diagnostics;
1811
+ }
1812
+ };
1813
+ function literalJsxString(attribute) {
1814
+ if (!attribute || !ts.isJsxAttribute(attribute) || !attribute.initializer) return null;
1815
+ if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
1816
+ const expression = jsxExpression(attribute);
1817
+ return expression && ts.isStringLiteralLike(expression) ? expression.text : null;
1818
+ }
1819
+ function unwrapStaticExpression(expression) {
1820
+ let current = expression;
1821
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current) || ts.isSatisfiesExpression(current)) current = current.expression;
1822
+ return current;
1823
+ }
1824
+ function routeReferencePath(expression, context, routePaths) {
1825
+ const candidate = unwrapStaticExpression(expression);
1826
+ if (ts.isCallExpression(candidate)) return routePaths.get(candidate) ?? null;
1827
+ if (!ts.isIdentifier(candidate) && !ts.isPropertyAccessExpression(candidate)) return null;
1828
+ const symbolNode = ts.isPropertyAccessExpression(candidate) ? candidate.name : candidate;
1829
+ const localSymbol = context.checker.getSymbolAtLocation(symbolNode);
1830
+ let symbol = localSymbol;
1831
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = context.checker.getAliasedSymbol(symbol);
1832
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
1833
+ if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) {
1834
+ const initializer = unwrapStaticExpression(declaration.initializer);
1835
+ if (ts.isCallExpression(initializer)) return routePaths.get(initializer) ?? null;
1836
+ }
1837
+ if (!ts.isIdentifier(candidate)) return null;
1838
+ const importSpecifier = localSymbol?.declarations?.find(ts.isImportSpecifier);
1839
+ if (!importSpecifier) return null;
1840
+ let importDeclaration = importSpecifier;
1841
+ while (importDeclaration.parent && !ts.isImportDeclaration(importDeclaration)) importDeclaration = importDeclaration.parent;
1842
+ if (!ts.isImportDeclaration(importDeclaration) || !ts.isStringLiteral(importDeclaration.moduleSpecifier) || !importDeclaration.moduleSpecifier.text.startsWith(".")) return null;
1843
+ const modulePath = path.resolve(path.dirname(importDeclaration.getSourceFile().fileName), importDeclaration.moduleSpecifier.text);
1844
+ const sourceStem = (filePath) => {
1845
+ const stem = filePath.replace(/\\/g, "/").replace(/\.[cm]?[jt]sx?$/, "");
1846
+ return process.platform === "win32" ? stem.toLowerCase() : stem;
1847
+ };
1848
+ const target = context.sourceFiles.find((sourceFile) => {
1849
+ const stem = sourceStem(sourceFile.fileName);
1850
+ const expected = sourceStem(modulePath);
1851
+ return stem === expected || stem === `${expected}/index`;
1852
+ });
1853
+ if (!target) return null;
1854
+ const importedName = importSpecifier.propertyName?.text ?? importSpecifier.name.text;
1855
+ let pathname = null;
1856
+ visit(target, (node) => {
1857
+ if (pathname !== null || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || node.name.text !== importedName || !node.initializer || containingFunction(node)) return;
1858
+ const initializer = unwrapStaticExpression(node.initializer);
1859
+ if (ts.isCallExpression(initializer)) pathname = routePaths.get(initializer) ?? null;
1860
+ });
1861
+ return pathname;
1862
+ }
1863
+ function staticObjectKeys(object) {
1864
+ const keys = /* @__PURE__ */ new Set();
1865
+ for (const property of object.properties) {
1866
+ if (ts.isSpreadAssignment(property)) return null;
1867
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) && !ts.isMethodDeclaration(property)) return null;
1868
+ if (ts.isComputedPropertyName(property.name)) return null;
1869
+ if (!ts.isIdentifier(property.name) && !ts.isStringLiteralLike(property.name) && !ts.isNumericLiteral(property.name)) return null;
1870
+ keys.add(property.name.text);
1871
+ }
1872
+ return keys;
1873
+ }
1874
+ function routeParameterNames(pathname) {
1875
+ const names = /* @__PURE__ */ new Set();
1876
+ for (const segment of pathname.split("/")) {
1877
+ if (!segment.startsWith("{") || !segment.endsWith("}")) continue;
1878
+ const raw = segment.slice(1, -1).trim();
1879
+ const name = (raw.startsWith("*") ? raw.slice(1) : raw).trim();
1880
+ if (name) names.add(name);
1881
+ }
1882
+ return [...names];
1883
+ }
1884
+ const linkContractRule = {
1885
+ id: "askr/link-contract",
1886
+ category: "correctness",
1887
+ severity: "error",
1888
+ description: "Link destinations must be unambiguous and use runtime-safe schemes.",
1889
+ analyze(context) {
1890
+ const diagnostics = [];
1891
+ const unsafe = /^(?:javascript|data|vbscript):/i;
1892
+ const routePaths = /* @__PURE__ */ new Map();
1893
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1894
+ if (name !== "route" && name !== "page") continue;
1895
+ const literal = node.arguments[0];
1896
+ if (!literal || !ts.isStringLiteral(literal)) continue;
1897
+ const pathname = literal.text.startsWith("/") ? normalizeRoutePrefix(literal.text) : name === "page" ? analyzedPagePrefix(literal.text) : null;
1898
+ if (pathname) routePaths.set(node, pathname);
1899
+ }
1900
+ walkRouteDefinitions(context, (call, name, scope) => {
1901
+ const pathname = analyzedRoutePath(call, name, scope);
1902
+ if (pathname) routePaths.set(call, pathname);
1903
+ });
1904
+ for (const sourceFile of context.sourceFiles) {
1905
+ const bindings = sourceBindings(sourceFile);
1906
+ if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "Link")) continue;
1907
+ visit(sourceFile, (node) => {
1908
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
1909
+ if (canonicalJsxName(node.tagName, bindings) !== "Link") return;
1910
+ const attributes = jsxAttributes(node);
1911
+ if ([...attributes.values()].some(ts.isJsxSpreadAttribute)) return;
1912
+ const to = attributes.get("to");
1913
+ const href = attributes.get("href");
1914
+ if (!to && !href) diagnostics.push(diagnostic(context, node.tagName, this, "<Link> requires a to or href destination."));
1915
+ else if (to && href) diagnostics.push(diagnostic(context, node.tagName, this, "<Link> cannot specify both to and href."));
1916
+ const destination = literalJsxString(href ?? to);
1917
+ 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."));
1918
+ const rawToExpression = jsxExpression(to);
1919
+ if (!rawToExpression) return;
1920
+ const toExpression = unwrapStaticExpression(rawToExpression);
1921
+ if (!ts.isCallExpression(toExpression)) return;
1922
+ if (canonicalCallName(toExpression.expression, bindings) !== "to") return;
1923
+ const rawParams = toExpression.arguments[1];
1924
+ if (!rawParams) return;
1925
+ const params = unwrapStaticExpression(rawParams);
1926
+ if (!ts.isObjectLiteralExpression(params)) return;
1927
+ const keys = staticObjectKeys(params);
1928
+ if (!keys) return;
1929
+ const routeExpression = toExpression.arguments[0];
1930
+ if (!routeExpression) return;
1931
+ const pathname = routeReferencePath(routeExpression, context, routePaths);
1932
+ if (!pathname) return;
1933
+ for (const parameter of routeParameterNames(pathname)) {
1934
+ if (keys.has(parameter)) continue;
1935
+ diagnostics.push(diagnostic(context, params, this, `Link destination is missing route parameter "${parameter}".`, `Pass '${parameter}' in the static to() parameter object.`));
1936
+ }
1937
+ });
1938
+ }
1939
+ return diagnostics;
1940
+ }
1941
+ };
1942
+ function packageName(manifest) {
1943
+ return typeof manifest.name === "string" ? manifest.name : "";
1944
+ }
1945
+ const ANALYZE_RULES = [
1946
+ parseErrorRule,
1947
+ stableControlBoundaryRule,
1228
1948
  stableRenderRule,
1949
+ renderScopeRequiredRule,
1950
+ exhaustiveDependenciesRule,
1951
+ forRowClosureCaptureRule,
1952
+ stableModuleIdentityRule,
1953
+ queryKeyContractRule,
1229
1954
  stateAccessRule,
1230
1955
  stateRenderWriteRule,
1231
1956
  resourceCancellationRule,
@@ -1233,6 +1958,7 @@ const ANALYZE_RULES = [
1233
1958
  lifecycleContractRule,
1234
1959
  streamContractRule,
1235
1960
  dataContractRule,
1961
+ linkContractRule,
1236
1962
  invalidationContractRule,
1237
1963
  forContractRule,
1238
1964
  controlContractRule,
@@ -1241,6 +1967,7 @@ const ANALYZE_RULES = [
1241
1967
  asyncComponentRule,
1242
1968
  routeRegistryRule,
1243
1969
  routePathRule,
1970
+ routeScopeStructureRule,
1244
1971
  dataCancellationRule,
1245
1972
  bootRegistryRule,
1246
1973
  islandContractRule,
@@ -1248,55 +1975,96 @@ const ANALYZE_RULES = [
1248
1975
  actionContractRule,
1249
1976
  actionPromiseRule,
1250
1977
  renderAllocationRule,
1251
- ssrGlobalsRule,
1252
1978
  {
1253
- id: "askr/framework-config",
1254
- category: "configuration",
1255
- severity: "error",
1256
- description: "TypeScript and Vite must use Askr's JSX/runtime wiring.",
1979
+ id: "askr/no-hardcoded-theme-token",
1980
+ category: "correctness",
1981
+ severity: "warning",
1982
+ description: "Runtime UI literals should use semantic theme styling.",
1257
1983
  analyze(context) {
1984
+ const workspacePackage = packageName(context.workspace.manifest);
1985
+ const ownsThemeTokens = workspacePackage === "@askrjs/themes";
1986
+ const mayHardcodeColors = ["@askrjs/askr", "@askrjs/themes"].includes(workspacePackage);
1258
1987
  const diagnostics = [];
1259
- const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx"));
1260
- if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics;
1261
- const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json");
1262
- const tsconfigSource = ts.sys.readFile(tsconfigPath);
1263
- if (context.program.getCompilerOptions().jsxImportSource !== "@askrjs/askr") {
1264
- let fix;
1265
- if (tsconfigSource) try {
1266
- const parsed = JSON.parse(tsconfigSource);
1267
- parsed.compilerOptions = {
1268
- ...parsed.compilerOptions && typeof parsed.compilerOptions === "object" && !Array.isArray(parsed.compilerOptions) ? parsed.compilerOptions : {},
1269
- jsx: "react-jsx",
1270
- jsxImportSource: "@askrjs/askr"
1271
- };
1272
- fix = {
1273
- description: "Configure TypeScript to use the Askr JSX runtime",
1274
- filePath: tsconfigPath,
1275
- start: 0,
1276
- end: tsconfigSource.length,
1277
- replacement: `${JSON.stringify(parsed, null, 2)}\n`
1278
- };
1279
- } catch {}
1280
- 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));
1988
+ const color = /(?:#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\()/i;
1989
+ for (const sourceFile of context.sourceFiles) {
1990
+ const checkColors = !mayHardcodeColors && !/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName) && color.test(sourceFile.text);
1991
+ const checkTokens = !ownsThemeTokens && sourceFile.text.includes("--ak-");
1992
+ if (!checkColors && !checkTokens) continue;
1993
+ visit(sourceFile, (node) => {
1994
+ const text = runtimeLiteralText(node);
1995
+ if (text === null) return;
1996
+ if (checkTokens && text.includes("--ak-")) diagnostics.push(diagnostic(context, node, this, "Runtime code names an Askr theme token directly; use a semantic class or data-* attribute instead.", "Move the token mapping to theme CSS and select it through a semantic class or data-* attribute."));
1997
+ else if (checkColors && color.test(text)) diagnostics.push(diagnostic(context, node, this, `Runtime UI literal '${text}' hardcodes a color instead of a theme token.`, "Use a semantic prop, theme variable, or design-system class."));
1998
+ });
1281
1999
  }
1282
- const viteConfig = context.sourceFiles.find((sourceFile) => /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")));
1283
- if (viteConfig) {
1284
- let pluginLocalName = null;
1285
- for (const statement of viteConfig.statements) {
1286
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/vite" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1287
- pluginLocalName = statement.importClause.namedBindings.elements.find((element) => (element.propertyName?.text ?? element.name.text) === "askr")?.name.text ?? null;
2000
+ return diagnostics;
2001
+ }
2002
+ },
2003
+ {
2004
+ id: "askr/no-effect-data-loading",
2005
+ category: "correctness",
2006
+ severity: "warning",
2007
+ description: "Fetch-to-state data loading should use resource rather than task effects.",
2008
+ analyze(context) {
2009
+ const diagnostics = [];
2010
+ for (const sourceFile of context.sourceFiles) {
2011
+ const bindings = sourceBindings(sourceFile);
2012
+ if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "task")) continue;
2013
+ const state = collectStateBindings(sourceFile, bindings);
2014
+ for (const { node, name } of sourceFacts(sourceFile).calls) {
2015
+ if (name !== "task") continue;
2016
+ const callback = node.arguments[0];
2017
+ if (!callback || !ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback) || !/\bfetch\s*\(/.test(callback.body.getText())) continue;
2018
+ let writesState = false;
2019
+ const walk = (candidate) => {
2020
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && state.setters.has(candidate.expression.text)) writesState = true;
2021
+ ts.forEachChild(candidate, walk);
2022
+ };
2023
+ walk(callback.body);
2024
+ if (!writesState) continue;
2025
+ 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."));
1288
2026
  }
1289
- let pluginCalled = false;
1290
- if (pluginLocalName) visit(viteConfig, (node) => {
1291
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === pluginLocalName) pluginCalled = true;
2027
+ }
2028
+ return diagnostics;
2029
+ }
2030
+ },
2031
+ {
2032
+ id: "askr/testing-contract",
2033
+ category: "correctness",
2034
+ severity: "error",
2035
+ description: "Canonical test dispatches must be synchronously flushed before assertions.",
2036
+ analyze(context) {
2037
+ const diagnostics = [];
2038
+ for (const sourceFile of context.sourceFiles) {
2039
+ let canonicalDispatch = false;
2040
+ 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;
2041
+ if (!canonicalDispatch) continue;
2042
+ visit(sourceFile, (node) => {
2043
+ if (!ts.isBlock(node)) return;
2044
+ let pending = null;
2045
+ for (const statement of node.statements) {
2046
+ const text = statement.getText();
2047
+ let dispatch = null;
2048
+ const findDispatch = (candidate) => {
2049
+ if (ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && candidate.expression.text === "dispatch") dispatch = candidate;
2050
+ ts.forEachChild(candidate, findDispatch);
2051
+ };
2052
+ findDispatch(statement);
2053
+ if (dispatch) pending = dispatch;
2054
+ if (pending && /(?:^|\.)(?:flush)\s*\(/.test(text)) pending = null;
2055
+ if (pending && /\b(?:expect|assert)\s*\(/.test(text)) {
2056
+ diagnostics.push(diagnostic(context, pending, this, "dispatch() reaches an assertion without a synchronous flush().", "Call flush() or result.flush() before the next assertion."));
2057
+ pending = null;
2058
+ }
2059
+ }
1292
2060
  });
1293
- const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite");
1294
- if (dependencyPresent && pluginCalled) return diagnostics;
1295
- 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."));
1296
2061
  }
1297
2062
  return diagnostics;
1298
2063
  }
1299
- }
2064
+ },
2065
+ ssrGlobalsRule,
2066
+ frameworkConfigRule,
2067
+ importSubpathRule
1300
2068
  ];
1301
2069
  function configuredSeverity(rule, configuration) {
1302
2070
  return configuration.rules[rule.id] ?? rule.severity;