mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* chore(tooling): migrate workspace to TypeScript 7 * fix(docker): stage plugin manifests before native web type checking * perf(guard): batch native parsing with bounded AST retention * fix(tooling): port main's AST tests to native TypeScript
176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import * as ts from "@typescript/native/unstable/ast";
|
|
import { parseSource } from "../typescript/source.ts";
|
|
|
|
import type { GuardContext } from "./core.ts";
|
|
|
|
const guardModule = "scripts/guard.ts";
|
|
const guardLibraryPrefix = "scripts/lib/guard/";
|
|
|
|
function repositoryPath(filePath: string): string {
|
|
return filePath.split(path.sep).join("/");
|
|
}
|
|
|
|
async function collectTypeScriptSources(
|
|
root: string,
|
|
directory = "scripts",
|
|
): Promise<Map<string, string>> {
|
|
const sources = new Map<string, string>();
|
|
const entries = await readdir(path.join(root, directory), { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const relativePath = repositoryPath(path.join(directory, entry.name));
|
|
if (entry.isDirectory()) {
|
|
for (const [file, source] of await collectTypeScriptSources(root, relativePath)) {
|
|
sources.set(file, source);
|
|
}
|
|
} else if (entry.isFile() && /\.(?:[cm]?ts|tsx)$/.test(entry.name)) {
|
|
sources.set(relativePath, await readFile(path.join(root, relativePath), "utf8"));
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
export async function loadScriptsArchitectureSources(repoRoot: string): Promise<Map<string, string>> {
|
|
return collectTypeScriptSources(repoRoot);
|
|
}
|
|
|
|
function importsFrom(source: string): string[] {
|
|
const imports: string[] = [];
|
|
const visit = (node: ts.Node): void => {
|
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && (ts.isStringLiteral(node.moduleSpecifier) || ts.isNoSubstitutionTemplateLiteral(node.moduleSpecifier))) {
|
|
imports.push(node.moduleSpecifier.text);
|
|
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && node.moduleReference.expression && (ts.isStringLiteral(node.moduleReference.expression) || ts.isNoSubstitutionTemplateLiteral(node.moduleReference.expression))) {
|
|
imports.push(node.moduleReference.expression.text);
|
|
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
|
|
imports.push(node.argument.literal.text);
|
|
} else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
|
|
const argument = node.arguments[0];
|
|
if (argument && (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))) imports.push(argument.text);
|
|
}
|
|
node.forEachChild(visit);
|
|
};
|
|
visit(parseSource("module.ts", source));
|
|
return imports;
|
|
}
|
|
|
|
function hasCliProcessControl(source: string): boolean {
|
|
const sourceFile = parseSource("module.ts", source);
|
|
let found = false;
|
|
const visit = (node: ts.Node): void => {
|
|
if (
|
|
ts.isPropertyAccessExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === "process" &&
|
|
["argv", "exit", "exitCode"].includes(node.name.text)
|
|
) {
|
|
found = true;
|
|
return;
|
|
}
|
|
node.forEachChild(visit);
|
|
};
|
|
visit(sourceFile);
|
|
return found;
|
|
}
|
|
|
|
function resolveImport(
|
|
importer: string,
|
|
specifier: string,
|
|
sources: ReadonlyMap<string, string>,
|
|
): string | undefined {
|
|
if (!specifier.startsWith(".")) return undefined;
|
|
const unresolved = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier));
|
|
const candidates = [
|
|
unresolved,
|
|
unresolved.replace(/\.js$/, ".ts"),
|
|
unresolved.replace(/\.mjs$/, ".mts"),
|
|
unresolved.replace(/\.cjs$/, ".cts"),
|
|
`${unresolved}.ts`,
|
|
`${unresolved}/index.ts`,
|
|
];
|
|
return candidates.find((candidate) => sources.has(candidate)) ?? unresolved;
|
|
}
|
|
|
|
function cycleErrors(graph: ReadonlyMap<string, readonly string[]>, roots: readonly string[]): string[] {
|
|
const errors: string[] = [];
|
|
const visited = new Set<string>();
|
|
const active = new Set<string>();
|
|
const stack: string[] = [];
|
|
|
|
function visit(module: string): void {
|
|
if (active.has(module)) {
|
|
const start = stack.indexOf(module);
|
|
errors.push(`module cycle: ${[...stack.slice(start), module].join(" -> ")}`);
|
|
return;
|
|
}
|
|
if (visited.has(module)) return;
|
|
visited.add(module);
|
|
active.add(module);
|
|
stack.push(module);
|
|
for (const dependency of graph.get(module) ?? []) visit(dependency);
|
|
stack.pop();
|
|
active.delete(module);
|
|
}
|
|
|
|
for (const root of roots) visit(root);
|
|
return errors;
|
|
}
|
|
|
|
export function scriptsArchitectureErrors(sources: ReadonlyMap<string, string>): string[] {
|
|
const errors: string[] = [];
|
|
const graph = new Map<string, string[]>();
|
|
|
|
for (const [module, source] of sources) {
|
|
const dependencies: string[] = [];
|
|
for (const specifier of importsFrom(source)) {
|
|
const dependency = resolveImport(module, specifier, sources);
|
|
if (dependency != null) dependencies.push(dependency);
|
|
|
|
if (
|
|
dependency?.startsWith(guardLibraryPrefix) &&
|
|
module !== guardModule &&
|
|
!module.startsWith(guardLibraryPrefix)
|
|
) {
|
|
errors.push(`${module} must consume guard policy through ${guardModule}, not ${dependency}`);
|
|
}
|
|
|
|
if (!module.startsWith(guardLibraryPrefix)) continue;
|
|
if (specifier.startsWith("node:") || specifier.startsWith("@typescript/native/")) continue;
|
|
// Syntax tooling is shared by independent guards, without importing policy.
|
|
if (dependency?.startsWith("scripts/lib/typescript/") && sources.has(dependency)) continue;
|
|
if (dependency != null && specifier.startsWith(".") && !sources.has(dependency)) {
|
|
errors.push(`${module} imports missing module ${dependency}`);
|
|
}
|
|
if (dependency == null || !dependency.startsWith(guardLibraryPrefix)) {
|
|
errors.push(`${module} imports ${specifier} outside the guard library closure`);
|
|
}
|
|
if (dependency === guardModule) {
|
|
errors.push(`${module} must not depend on the guard CLI entrypoint`);
|
|
}
|
|
}
|
|
graph.set(module, dependencies.filter((dependency) => sources.has(dependency)));
|
|
|
|
if (module.startsWith(guardLibraryPrefix) && hasCliProcessControl(source)) {
|
|
errors.push(`${module} contains CLI process control; keep entrypoint effects in ${guardModule}`);
|
|
}
|
|
}
|
|
|
|
errors.push(
|
|
...cycleErrors(graph, [
|
|
...[...sources.keys()].filter((module) => module.startsWith("scripts/lib/")),
|
|
]),
|
|
);
|
|
return [...new Set(errors)].sort();
|
|
}
|
|
|
|
export async function checkScriptsLibraryArchitecture(context: GuardContext): Promise<boolean> {
|
|
const errors = scriptsArchitectureErrors(await loadScriptsArchitectureSources(context.repoRoot));
|
|
if (errors.length > 0) {
|
|
console.error("Scripts library architecture violations found:");
|
|
for (const error of errors) console.error(`- ${error}`);
|
|
return false;
|
|
}
|
|
console.log("Scripts library architecture check passed: workflow control and guard internals stay layered.");
|
|
return true;
|
|
}
|