返回 DeepSeek-Reasonix
check-app-layers.mjs
根目录 / desktop / frontend / scripts / check-app-layers.mjs
1 #!/usr/bin/env node
2 import { existsSync, readdirSync, readFileSync } from "node:fs";
3 import { basename, dirname, join, relative, resolve } from "node:path";
4 import { fileURLToPath, pathToFileURL } from "node:url";
5 import ts from "typescript";
6
7 const common = new Set(["useCommittedSlot.ts", "useCommittedCommand.ts", "useCommittedAsyncCommand.ts", "commandOutcome.ts", "composeDomRef.ts", "subscriptionScope.ts"]);
8 const domNames = new Set(["window", "document", "HTMLElement", "HTMLDivElement", "ReactNode", "SyntheticEvent"]);
9
10 function sourceFiles(root) {
11 if (!existsSync(root)) return [];
12 return readdirSync(root, { withFileTypes: true }).flatMap((entry) =>
13 entry.isDirectory() ? sourceFiles(join(root, entry.name))
14 : /\.[cm]?[jt]sx?$/.test(entry.name) ? [join(root, entry.name)] : []);
15 }
16
17 export function moduleEdges(code, file) {
18 const tree = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
19 const edges = [];
20 const identifiers = new Set();
21 const namedTypesOnly = (bindings) => bindings && ts.isNamedImports(bindings)
22 && bindings.elements.length > 0 && bindings.elements.every((entry) => entry.isTypeOnly);
23 function visit(node) {
24 // A DTO field named `window` is not a reference to the browser global.
25 if (ts.isIdentifier(node) && !(ts.isPropertySignature(node.parent) && node.parent.name === node)) identifiers.add(node.text);
26 if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
27 const clause = node.importClause;
28 edges.push({ specifier: node.moduleSpecifier.text,
29 typeOnly: Boolean(clause?.isTypeOnly || (clause && !clause.name && namedTypesOnly(clause.namedBindings))) });
30 return;
31 } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
32 edges.push({ specifier: node.moduleSpecifier.text, typeOnly: Boolean(node.isTypeOnly
33 || (node.exportClause && ts.isNamedExports(node.exportClause) && node.exportClause.elements.length > 0
34 && node.exportClause.elements.every((entry) => entry.isTypeOnly))) });
35 return;
36 } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword
37 || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
38 const argument = node.arguments[0];
39 if (argument && ts.isStringLiteral(argument)) edges.push({ specifier: argument.text, typeOnly: false });
40 else edges.push({ specifier: "<non-literal module>", typeOnly: false, unresolved: true });
41 } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)
42 && node.moduleReference.expression && ts.isStringLiteral(node.moduleReference.expression)) {
43 edges.push({ specifier: node.moduleReference.expression.text, typeOnly: Boolean(node.isTypeOnly) });
44 }
45 ts.forEachChild(node, visit);
46 }
47 visit(tree);
48 return { edges, identifiers };
49 }
50
51 export function checkAppLayers(sourceRoot, compilerOptions = {}) {
52 const failures = new Set();
53 const cache = new Map();
54 const normalize = (file) => relative(sourceRoot, file).replaceAll("\\", "/");
55 const parse = (file) => {
56 if (!cache.has(file)) cache.set(file, moduleEdges(readFileSync(file, "utf8"), file));
57 return cache.get(file);
58 };
59 const resolved = (edge, from) => {
60 if (edge.unresolved) return null;
61 const result = ts.resolveModuleName(edge.specifier, from, compilerOptions, ts.sys).resolvedModule;
62 return result && !result.isExternalLibraryImport ? result.resolvedFileName : null;
63 };
64 const files = ["app-shell", "app-runtime", "app-features", "app-domain"]
65 .flatMap((directory) => sourceFiles(join(sourceRoot, directory)));
66 for (const name of common) {
67 const file = join(sourceRoot, "lib", name);
68 if (existsSync(file)) files.push(file);
69 }
70 for (const file of files) {
71 const name = normalize(file);
72 const shell = name.startsWith("app-shell/");
73 const domain = /Owner\.ts$/.test(basename(file)) || basename(file) === "sessionTarget.ts" || name.startsWith("app-domain/");
74 const foundation = common.has(basename(file));
75 const visited = new Set();
76 function inspect(current, chain) {
77 if (visited.has(current)) return;
78 visited.add(current);
79 const parsed = parse(current);
80 if (domain && [...parsed.identifiers].some((id) => domNames.has(id))) {
81 failures.add(name + ": domain reaches DOM/React objects through " + chain.join(" -> "));
82 }
83 for (const edge of parsed.edges) {
84 if (edge.typeOnly) continue;
85 if (/\.(?:css|svg|png|webp|woff2?)(?:\?.*)?$/.test(edge.specifier)
86 && existsSync(resolve(dirname(current), edge.specifier.split("?")[0]))) continue;
87 const target = resolved(edge, current);
88 const targetName = target ? normalize(target) : edge.specifier;
89 const next = [...chain, targetName];
90 if (edge.unresolved || (!target && edge.specifier.startsWith("."))) {
91 failures.add(name + ": unresolvable runtime dependency " + next.join(" -> "));
92 }
93 if (domain && (/^react(?:-dom)?(?:\/|$)/.test(edge.specifier)
94 || /^(?:app-shell|components)\//.test(targetName))) {
95 failures.add(name + ": domain reaches presentation through " + next.join(" -> "));
96 }
97 if (!shell && targetName.startsWith("app-shell/")) {
98 failures.add(name + ": upstream reaches presentation through " + next.join(" -> "));
99 }
100 if (foundation && /^app-(?:runtime|features|shell)\//.test(targetName)) {
101 failures.add(name + ": shared primitive reaches App through " + next.join(" -> "));
102 }
103 if (shell && targetName === "lib/bridge.ts") {
104 failures.add(name + ": presentation reaches bridge through " + next.join(" -> "));
105 }
106 // Existing leaf components retain their own contracts; follow shell-local
107 // wrappers and the complete runtime graph of domain/common modules.
108 if (target && (domain || foundation || (shell && targetName.startsWith("app-shell/")))) inspect(target, next);
109 }
110 }
111 inspect(file, [name]);
112 }
113 return [...failures];
114 }
115
116 if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
117 const frontend = dirname(dirname(fileURLToPath(import.meta.url)));
118 const config = ts.readConfigFile(join(frontend, "tsconfig.json"), ts.sys.readFile);
119 if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n"));
120 const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, frontend);
121 const failures = checkAppLayers(join(frontend, "src"), parsed.options);
122 for (const failure of failures) console.error("check-app-layers: " + failure);
123 if (failures.length) process.exitCode = 1;
124 else console.log("check-app-layers: migrated App modules satisfy the AST dependency contracts");
125 }
126
126 lines Plain Text