88 lines
3.8 KiB
JavaScript
88 lines
3.8 KiB
JavaScript
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(/<script\b[^>]*>([\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)`)
|
|
}
|