mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Squash main through e03d5c23f3.
Preserve scoped funds/concurrency preflight, original Run workspace authority,
manual recovery, registration invite, and complete feature-test packaging binding.
Combine unified ProjectView, telemetry, previews, media search, BYOK and TS7.
Synchronize prototype profile version and release test fixtures.
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;
|
|
}
|