148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
interface ExplainRoute {
|
|
kind: "page" | "api" | "realtime";
|
|
path: string;
|
|
source: string;
|
|
execution: string;
|
|
canPrerender: boolean;
|
|
needsClientRuntime: boolean;
|
|
needsServerRuntime: boolean;
|
|
hydrationStrategy: string | null;
|
|
reasons?: string[];
|
|
cachePolicy?: Record<string, string>;
|
|
requiredPermission?: string | null;
|
|
}
|
|
|
|
interface ExplainReport {
|
|
frameworkVersion: string;
|
|
adapter: string;
|
|
routes: ExplainRoute[];
|
|
assets: Array<{ file: string; bytes: number }>;
|
|
measurements: Record<string, number>;
|
|
budgetViolations: Array<{ metric: string; budget: number; actual: number }>;
|
|
}
|
|
|
|
export interface Explanation {
|
|
target: string;
|
|
subject: string;
|
|
summary: string;
|
|
reasons: string[];
|
|
evidence: Record<string, unknown>;
|
|
}
|
|
|
|
function loadReport(root: string): ExplainReport {
|
|
const path = join(resolve(root), "dist", "build-report.json");
|
|
if (!existsSync(path)) {
|
|
throw new Error(
|
|
"WRN-EXPLAIN-NO-BUILD: run `wrnexus build` before requesting build explanations.",
|
|
);
|
|
}
|
|
return JSON.parse(readFileSync(path, "utf8")) as ExplainReport;
|
|
}
|
|
|
|
function routeMatch(routes: ExplainRoute[], subject: string): ExplainRoute | undefined {
|
|
const normalized = subject.startsWith("/") ? subject : `/${subject}`;
|
|
return routes.find((route) => route.path === normalized || route.source.includes(subject));
|
|
}
|
|
|
|
export function explainBuildDecision(root: string, target: string, subject = ""): Explanation {
|
|
const report = loadReport(root);
|
|
if (
|
|
target === "route" ||
|
|
target === "hydration" ||
|
|
target === "cache" ||
|
|
target === "permission"
|
|
) {
|
|
const route =
|
|
target === "permission"
|
|
? report.routes.find((item) => item.requiredPermission === subject)
|
|
: routeMatch(report.routes, subject);
|
|
if (!route) throw new Error(`WRN-EXPLAIN-NOT-FOUND: no route or source matches '${subject}'.`);
|
|
if (target === "cache") {
|
|
const policy = route.cachePolicy ?? {};
|
|
const entries = Object.entries(policy);
|
|
return {
|
|
target,
|
|
subject: route.path,
|
|
summary: entries.length
|
|
? `Route cache uses '${policy.strategy ?? "framework-default"}' strategy.`
|
|
: "Route has no explicit cache policy and uses safe framework defaults.",
|
|
reasons: entries.length
|
|
? entries.map(([name, value]) => `${name} = ${value}`)
|
|
: ["responses remain private/revalidated unless an explicit safe policy enables reuse"],
|
|
evidence: { source: route.source, cachePolicy: policy, execution: route.execution },
|
|
};
|
|
}
|
|
if (target === "permission") {
|
|
const requested = subject.startsWith("/") ? undefined : subject;
|
|
const matches = report.routes.filter((item) =>
|
|
requested ? item.requiredPermission === requested : item.path === route.path,
|
|
);
|
|
return {
|
|
target,
|
|
subject: requested ?? route.path,
|
|
summary: matches.length
|
|
? `${matches.length} route(s) require this permission.`
|
|
: "No built route declares this permission.",
|
|
reasons: matches.length
|
|
? matches.map((item) => `${item.path} declares security.permission in ${item.source}`)
|
|
: ["authorization may still be enforced programmatically; inspect authz policies"],
|
|
evidence: { routes: matches },
|
|
};
|
|
}
|
|
const reasons = route.reasons?.length ? route.reasons : ["no dynamic requirement was detected"];
|
|
return {
|
|
target,
|
|
subject: route.path,
|
|
summary:
|
|
target === "hydration"
|
|
? route.needsClientRuntime
|
|
? `Hydration uses '${route.hydrationStrategy ?? "load"}' because client runtime is required.`
|
|
: "Hydration is omitted because no client runtime is required."
|
|
: `Route execution is '${route.execution}'${route.canPrerender ? " and can prerender" : " and cannot prerender"}.`,
|
|
reasons,
|
|
evidence: { ...route },
|
|
};
|
|
}
|
|
if (target === "bundle") {
|
|
const assets = [...report.assets].sort((a, b) => b.bytes - a.bytes);
|
|
return {
|
|
target,
|
|
subject: subject || "production bundle",
|
|
summary: `${assets.length} emitted assets; largest is ${assets[0]?.file ?? "none"}.`,
|
|
reasons: assets.slice(0, 10).map((asset) => `${asset.file}: ${asset.bytes} bytes`),
|
|
evidence: { measurements: report.measurements, largestAssets: assets.slice(0, 10) },
|
|
};
|
|
}
|
|
if (target === "build") {
|
|
return {
|
|
target,
|
|
subject: "production build",
|
|
summary: `${report.routes.length} routes target the ${report.adapter} adapter.`,
|
|
reasons: report.budgetViolations.length
|
|
? report.budgetViolations.map(
|
|
(item) => `${item.metric} exceeds ${item.budget} with ${item.actual}`,
|
|
)
|
|
: ["all configured performance budgets pass"],
|
|
evidence: {
|
|
frameworkVersion: report.frameworkVersion,
|
|
adapter: report.adapter,
|
|
measurements: report.measurements,
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`WRN-EXPLAIN-TARGET: unsupported target '${target}'.`);
|
|
}
|
|
|
|
export function runExplain(root: string, target: string, subject: string, args: string[]): void {
|
|
const explanation = explainBuildDecision(root, target, subject);
|
|
if (args.includes("--json")) {
|
|
console.log(JSON.stringify(explanation, null, 2));
|
|
return;
|
|
}
|
|
console.log(explanation.summary);
|
|
explanation.reasons.forEach((reason, index) => console.log(` ${index + 1}. ${reason}`));
|
|
}
|