@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.
- package/dist/analyze.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/{guardrails-XypvEZOT.js → guardrails-L60OAzIp.js} +1 -1
- package/dist/{runner-42nqhW9u.js → runner-CYvonJ0C.js} +845 -77
- package/dist/{runner-CEt07ieG.js → runner-DzWc9R4G.js} +1 -1
- package/dist/ssg-config.d.ts +68 -1
- package/dist/ssg.js +310 -7
- package/dist/templates/ssr/src/pages/example.tsx +1 -1
- package/dist/templates/ssr/src/styles.css +7 -0
- package/dist/templates/startkit/src/components/app-header.tsx +1 -1
- package/dist/templates/startkit/src/components/page-header.tsx +1 -7
- package/dist/templates/startkit/src/pages/workspace/accounts/index.tsx +1 -1
- package/dist/templates/startkit/src/pages/workspace/settings.tsx +1 -1
- package/dist/templates/startkit/src/styles/components.css +10 -0
- package/package.json +9 -6
|
@@ -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: "
|
|
834
|
+
description: "Static route paths must satisfy the runtime authoring contract.",
|
|
741
835
|
analyze(context) {
|
|
742
836
|
const diagnostics = [];
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
diagnostics.push(diagnostic(context,
|
|
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:
|
|
757
|
-
end:
|
|
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
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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/
|
|
1254
|
-
category: "
|
|
1255
|
-
severity: "
|
|
1256
|
-
description: "
|
|
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
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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;
|