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