Files
Marc ChanandGitHub 417b81c752 [OPEND-4240] chore(tooling): migrate workspace to TypeScript 7 (#8957)
* 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
2026-10-22 08:40:45 +00:00

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;
}