import { readdir, readFile } from 'node:fs/promises' import { existsSync, statSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const sourceRoots = ['electron', 'packages/client/src', 'packages/server/src'] const entrypoints = [ 'electron/main.ts', 'electron/preload.ts', 'electron/backend.ts', 'packages/client/src/main.ts', 'packages/server/src/index.ts', 'bin/hermes-ui.mjs', 'bin/hermes-ui-mcp.mjs', // Compile-time request-shape assertions are intentionally never imported. 'packages/client/src/api/hermes/stt.contract.ts', // The documented, frozen Arco adapter API is kept alongside useMessage/useDialog. 'packages/client/src/composables/useNotification.ts', ] async function collect(directory) { const files = [] for (const entry of await readdir(path.join(root, directory), { withFileTypes: true })) { if (['node_modules', '__pycache__'].includes(entry.name)) continue const file = `${directory}/${entry.name}` if (entry.isDirectory()) files.push(...await collect(file)) else if (/\.(?:[cm]?[jt]sx?|vue)$/.test(file) && !file.endsWith('.d.ts')) files.push(file) } return files } function resolveImport(from, specifier) { const base = specifier.startsWith('@/') ? path.join(root, 'packages/client/src', specifier.slice(2)) : specifier.startsWith('.') ? path.resolve(root, path.dirname(from), specifier) : null if (!base) return null const candidates = [base, ...['.ts', '.tsx', '.vue', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.vue', '/index.js'].map(extension => base + extension)] const found = candidates.find(file => existsSync(file) && statSync(file).isFile()) return found ? path.relative(root, found).split(path.sep).join('/') : null } const sources = (await Promise.all(sourceRoots.map(collect))).flat() const tests = await collect('tests') const files = [...new Set([...sources, ...tests, ...entrypoints])] const graph = new Map() for (const file of files) { let code = await readFile(path.join(root, file), 'utf8') if (file.endsWith('.vue')) { code = [...code.matchAll(/]*>([\s\S]*?)<\/script>/g)].map(match => match[1]).join('\n') } const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true) const dependencies = new Set() function visit(node) { let specifier if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { specifier = node.moduleSpecifier.text } else if (ts.isCallExpression(node) && node.arguments.length && ts.isStringLiteral(node.arguments[0]) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) { specifier = node.arguments[0].text } if (specifier) { const dependency = resolveImport(file, specifier) if (dependency) dependencies.add(dependency) } ts.forEachChild(node, visit) } visit(source) graph.set(file, dependencies) } const reachable = new Set() function visit(file) { if (reachable.has(file)) return reachable.add(file) for (const dependency of graph.get(file) || []) visit(dependency) } // Test-only helpers are part of the project and must not be mistaken for dead code. for (const file of [...entrypoints, ...tests]) visit(file) const orphans = sources.filter(file => !reachable.has(file) && !file.includes('/vendor/')).sort() if (orphans.length) { console.error(`Unreferenced source files:\n${orphans.map(file => `- ${file}`).join('\n')}`) process.exitCode = 1 } else { console.log(`Orphan check passed (${sources.length} source files; application, test and documented API entrypoints)`) }