Files
hermes-desktop/scripts/check-orphans.mjs
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

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)`)
}