import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; 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> { const sources = new Map(); 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> { return collectTypeScriptSources(repoRoot); } function importsFrom(source: string): string[] { return ts.preProcessFile(source, true, true).importedFiles.map(({ fileName }) => fileName); } function hasCliProcessControl(source: string): boolean { const sourceFile = ts.createSourceFile("module.ts", source, ts.ScriptTarget.Latest, true); 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; } ts.forEachChild(node, visit); }; visit(sourceFile); return found; } function resolveImport( importer: string, specifier: string, sources: ReadonlyMap, ): 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, roots: readonly string[]): string[] { const errors: string[] = []; const visited = new Set(); const active = new Set(); 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[] { const errors: string[] = []; const graph = new Map(); 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 === "typescript") 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 { 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; }