623 lines
23 KiB
JavaScript
623 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFile, readdir } from 'node:fs/promises'
|
|
import { existsSync } from 'node:fs'
|
|
import { execFileSync } from 'node:child_process'
|
|
import path from 'node:path'
|
|
|
|
const root = process.cwd()
|
|
const failures = []
|
|
|
|
function fail(message) {
|
|
failures.push(message)
|
|
}
|
|
|
|
async function readText(relativePath) {
|
|
return readFile(path.join(root, relativePath), 'utf8')
|
|
}
|
|
|
|
function requireFile(relativePath) {
|
|
if (!existsSync(path.join(root, relativePath))) {
|
|
fail(`Missing required harness file: ${relativePath}`)
|
|
}
|
|
}
|
|
|
|
function requireDir(relativePath) {
|
|
if (!existsSync(path.join(root, relativePath))) {
|
|
fail(`Missing required project directory: ${relativePath}`)
|
|
}
|
|
}
|
|
|
|
function gitLines(args) {
|
|
try {
|
|
return execFileSync('git', args, {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
})
|
|
.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.filter(Boolean)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function changedFilesFromGit() {
|
|
const files = new Set()
|
|
|
|
for (const file of gitLines(['diff', '--name-only'])) files.add(file)
|
|
for (const file of gitLines(['diff', '--name-only', '--cached'])) files.add(file)
|
|
for (const file of gitLines(['ls-files', '--others', '--exclude-standard'])) files.add(file)
|
|
|
|
const baseRef = process.env.GITHUB_BASE_REF
|
|
if (baseRef) {
|
|
const baseCandidates = [`origin/${baseRef}`, baseRef]
|
|
let foundPrBase = false
|
|
for (const base of baseCandidates) {
|
|
const diff = gitLines(['diff', '--name-only', `${base}...HEAD`])
|
|
if (diff.length > 0) {
|
|
foundPrBase = true
|
|
for (const file of diff) files.add(file)
|
|
break
|
|
}
|
|
}
|
|
if (process.env.GITHUB_ACTIONS === 'true' && !foundPrBase && files.size === 0) {
|
|
fail(`Unable to inspect PR diff against ${baseRef}; build checkout must fetch full history`)
|
|
}
|
|
} else {
|
|
const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])[0]
|
|
if (upstream) {
|
|
for (const file of gitLines(['diff', '--name-only', `${upstream}...HEAD`])) files.add(file)
|
|
}
|
|
}
|
|
|
|
return [...files].sort()
|
|
}
|
|
|
|
function isChatSessionChainFile(file) {
|
|
return file === 'packages/client/src/api/hermes/chat.ts'
|
|
|| file === 'packages/client/src/api/hermes/group-chat.ts'
|
|
|| file === 'packages/client/src/api/hermes/sessions.ts'
|
|
|| file === 'packages/client/src/stores/hermes/group-chat.ts'
|
|
|| file === 'packages/client/src/stores/hermes/chat.ts'
|
|
|| file === 'packages/server/src/controllers/hermes/sessions.ts'
|
|
|| file === 'packages/server/src/db/hermes/session-store.ts'
|
|
|| file === 'packages/server/src/routes/hermes/group-chat.ts'
|
|
|| file.startsWith('packages/client/src/components/hermes/group-chat/')
|
|
|| file.startsWith('packages/client/src/components/hermes/chat/')
|
|
|| file.startsWith('packages/server/src/lib/context-compressor/')
|
|
|| file.startsWith('packages/server/src/services/hermes/context-engine/')
|
|
|| file.startsWith('packages/server/src/services/hermes/group-chat/')
|
|
|| file.startsWith('packages/server/src/services/hermes/run-chat/')
|
|
|| file.startsWith('packages/server/src/services/hermes/agent-bridge/')
|
|
}
|
|
|
|
function isChatChainChangeFragment(file) {
|
|
return file.startsWith('docs/chat-chain-changes/')
|
|
&& file.endsWith('.md')
|
|
&& path.basename(file) !== 'README.md'
|
|
}
|
|
|
|
for (const file of [
|
|
'AGENTS.md',
|
|
'ARCHITECTURE.md',
|
|
'DEVELOPMENT.md',
|
|
'docs/harness/README.md',
|
|
'docs/harness/validation.md',
|
|
'docs/harness/worktree-runbook.md',
|
|
'docs/harness/pr-review.md',
|
|
'docs/chat-chain-changes/README.md',
|
|
// 构建 / 测试 / 类型检查的入口配置。缺任何一个都会让 npm run build 或
|
|
// npm run test 的行为与 CI 不一致,属于 harness 自身失效,必须先报出来。
|
|
'README.md',
|
|
'package.json',
|
|
'vite.config.ts',
|
|
'vitest.config.ts',
|
|
'playwright.config.ts',
|
|
'tsconfig.json',
|
|
'electron/main.ts',
|
|
'electron/preload.ts',
|
|
'electron/backend.ts',
|
|
'electron/tsconfig.json',
|
|
'electron-builder.yml',
|
|
'playwright.electron.config.ts',
|
|
'docs/electron.md',
|
|
'.github/workflows/electron-package.yml',
|
|
]) {
|
|
requireFile(file)
|
|
}
|
|
|
|
for (const dir of [
|
|
'packages/client/src',
|
|
'packages/server/src',
|
|
'packages/skills',
|
|
'tests/client',
|
|
'tests/server',
|
|
'tests/e2e',
|
|
'.github/workflows',
|
|
'docs/chat-chain-changes',
|
|
'tests/electron',
|
|
]) {
|
|
requireDir(dir)
|
|
}
|
|
|
|
// 禁止目录:AGENTS.md 的 Red Lines 明确写明 packages/desktop 与 packages/website
|
|
// 已删除、不得重新引入;ekko-agent 与 esp32-c3 同样是已砍掉的旧运行时/固件包。
|
|
// 这里只检查目录是否存在,不检查字符串引用——个别源码里还留着指向这些路径的
|
|
// 字符串(例如 scripts/build-server.mjs 与 mcu-firmware.ts),那些引用有
|
|
// existsSync 保护,不会导致构建失败,清理由各自负责。
|
|
//
|
|
// 注意:reference_code 暂不在此列,按任务书要求留到 Phase 009 再加,避免中途误红。
|
|
for (const dir of [
|
|
'packages/desktop',
|
|
'packages/website',
|
|
'packages/ekko-agent',
|
|
'packages/esp32-c3',
|
|
]) {
|
|
if (existsSync(path.join(root, dir))) {
|
|
fail(`Retired package directory must not come back: ${dir}`)
|
|
}
|
|
}
|
|
|
|
const agents = await readText('AGENTS.md')
|
|
const agentLines = agents.trimEnd().split(/\r?\n/)
|
|
if (agentLines.length > 120) {
|
|
fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`)
|
|
}
|
|
|
|
for (const requiredLink of [
|
|
'DEVELOPMENT.md',
|
|
'ARCHITECTURE.md',
|
|
'docs/harness/README.md',
|
|
'docs/harness/validation.md',
|
|
'docs/harness/worktree-runbook.md',
|
|
'docs/harness/pr-review.md',
|
|
]) {
|
|
if (!agents.includes(requiredLink)) {
|
|
fail(`AGENTS.md must link to ${requiredLink}`)
|
|
}
|
|
}
|
|
|
|
const packageJson = JSON.parse(await readText('package.json'))
|
|
for (const scriptName of [
|
|
'harness:check',
|
|
'test',
|
|
'test:coverage',
|
|
'test:e2e',
|
|
'build',
|
|
'build:electron',
|
|
'electron:setup',
|
|
'test:electron',
|
|
'pack',
|
|
'dist',
|
|
]) {
|
|
if (!packageJson.scripts?.[scriptName]) {
|
|
fail(`package.json is missing script: ${scriptName}`)
|
|
}
|
|
}
|
|
|
|
if (packageJson.main !== 'dist/electron/main.js') {
|
|
fail('package.json main must point at the built Electron main process')
|
|
}
|
|
|
|
// 禁止脚本:desktop / website 两个包已删除(见上面的禁止目录检查),
|
|
// 它们的构建脚本必须一起消失,否则会留下一个必然失败的 npm script,
|
|
// 让人以为「跑一下 npm run build:desktop 还能用」。
|
|
const scriptNames = Object.keys(packageJson.scripts || {})
|
|
for (const forbidden of [
|
|
/^desktop:/,
|
|
/^build:desktop/,
|
|
/^dev:website$/,
|
|
/^build:website$/,
|
|
]) {
|
|
for (const scriptName of scriptNames) {
|
|
if (forbidden.test(scriptName)) {
|
|
fail(`package.json must not keep a script for a retired package: ${scriptName} (matched ${forbidden})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 第二 UI 库红线:Phase 007 已把 naive-ui 全量迁移到 Arco Design Vue。
|
|
// 同时装两套 UI 库会让 CSS 体积、主题变量与组件解析产生难以定位的冲突,
|
|
// 因此 package.json 里只允许出现 @arco-design/web-vue 一套。
|
|
const UI_LIBRARIES = [
|
|
'@arco-design/web-vue',
|
|
'naive-ui',
|
|
'element-plus',
|
|
'ant-design-vue',
|
|
'primevue',
|
|
'vuetify',
|
|
'quasar',
|
|
]
|
|
const declaredDependencies = Object.keys({
|
|
...packageJson.dependencies,
|
|
...packageJson.devDependencies,
|
|
})
|
|
for (const dependency of declaredDependencies) {
|
|
if (!UI_LIBRARIES.includes(dependency)) continue
|
|
if (dependency === '@arco-design/web-vue') continue
|
|
fail(`Only one UI library is allowed (@arco-design/web-vue); remove dependency: ${dependency}`)
|
|
}
|
|
if (!declaredDependencies.includes('@arco-design/web-vue')) {
|
|
fail('package.json must depend on @arco-design/web-vue (the only allowed UI library)')
|
|
}
|
|
|
|
const architecture = await readText('ARCHITECTURE.md')
|
|
for (const phrase of [
|
|
'packages/client/src',
|
|
'packages/server/src',
|
|
'packages/skills',
|
|
'HERMES_WEB_UI_HOME',
|
|
'agent-bridge/python/',
|
|
]) {
|
|
if (!architecture.includes(phrase)) {
|
|
fail(`ARCHITECTURE.md should document: ${phrase}`)
|
|
}
|
|
}
|
|
|
|
const buildWorkflow = await readText('.github/workflows/build.yml')
|
|
if (!buildWorkflow.includes('npm run harness:check')) {
|
|
fail('Build workflow must run npm run harness:check')
|
|
}
|
|
if (!buildWorkflow.includes('fetch-depth: 0')) {
|
|
fail('Build workflow checkout must use fetch-depth: 0 so harness:check can inspect PR diffs')
|
|
}
|
|
|
|
// node-pty 的原生模块必须被编译出来。
|
|
//
|
|
// `npm ci --ignore-scripts` 会跳过所有 install 脚本,而 node-pty 的 install
|
|
// 脚本正是把可加载的原生模块放到位的那个。少了它,终端功能会被静默禁用 ——
|
|
// 服务端只打一条 warning 就继续跑,不会有任何失败信号。Linux 上 node-pty
|
|
// 不发预编译产物,必须从源码编译,这一步是硬需求。
|
|
//
|
|
// 判据是「这个 workflow 是否真的需要原生模块」,不是「是否出现 npm ci」:
|
|
// * 跑测试或构建的 workflow —— 终端路由会被加载,少了原生模块就有问题;
|
|
// * 打包 node_modules 交付给用户的 workflow —— 交付物里必须带着能用的
|
|
// 原生模块,否则用户装完发现终端功能是坏的。
|
|
// npm-lockfile-check 只校验 lockfile 同步,不碰运行时,不需要这一步。
|
|
const WORKFLOWS_NEEDING_NATIVE_MODULES = ['npm run test', 'npm run build', 'npm run test:e2e', 'npm prune']
|
|
for (const workflowFile of await readdir(path.join(root, '.github/workflows'))) {
|
|
if (!workflowFile.endsWith('.yml') && !workflowFile.endsWith('.yaml')) continue
|
|
const relativeWorkflow = `.github/workflows/${workflowFile}`
|
|
const text = await readText(relativeWorkflow)
|
|
if (!text.includes('npm ci --ignore-scripts')) continue
|
|
const needsNativeModule = WORKFLOWS_NEEDING_NATIVE_MODULES.some(command => text.includes(command))
|
|
if (!needsNativeModule) continue
|
|
if (!text.includes('npm rebuild node-pty')) {
|
|
fail(
|
|
[
|
|
`${relativeWorkflow} installs dependencies with npm ci --ignore-scripts`,
|
|
'but does not run npm rebuild node-pty.',
|
|
'node-pty ships no prebuilt binary for Linux, so without the rebuild the',
|
|
'terminal feature is silently disabled on the runner.',
|
|
].join(' '),
|
|
)
|
|
}
|
|
}
|
|
|
|
// macOS 没有被任何托管 runner 覆盖:Build 与 Playwright workflow 都跑在
|
|
// ubuntu-latest 上。macOS 的验证必须以手动触发的方式存在,且不能挂在
|
|
// 主流水线里 —— 没有 macOS runner 时,job 会永远停在等待状态,把整个
|
|
// workflow 拖成 in progress。
|
|
const macosWorkflowPath = '.github/workflows/macos-verification.yml'
|
|
if (!existsSync(path.join(root, macosWorkflowPath))) {
|
|
fail(`Missing macOS verification workflow: ${macosWorkflowPath}`)
|
|
} else {
|
|
const macosWorkflow = await readText(macosWorkflowPath)
|
|
if (!macosWorkflow.includes('workflow_dispatch')) {
|
|
fail('macOS verification workflow must be workflow_dispatch only: without a hosted macOS runner it would block the pipeline')
|
|
}
|
|
if (/^\s*push:/m.test(macosWorkflow) || /^\s*pull_request:/m.test(macosWorkflow)) {
|
|
fail('macOS verification workflow must not run on push or pull_request: no hosted macOS runner is available')
|
|
}
|
|
if (!macosWorkflow.includes('npm run harness:check')) {
|
|
fail('macOS verification workflow must run npm run harness:check')
|
|
}
|
|
}
|
|
|
|
// DEVELOPMENT.md 是新贡献者拿到跨平台仓库后的第一份指引。如果它漏了
|
|
// node-pty 的编译步骤(Linux 上是硬需求),他们照着走会得到一个终端功能
|
|
// 静默失效的环境,而且没有任何报错。
|
|
const developmentDoc = await readText('DEVELOPMENT.md')
|
|
for (const phrase of [
|
|
'npm rebuild node-pty',
|
|
'npm ci --ignore-scripts',
|
|
]) {
|
|
if (!developmentDoc.includes(phrase)) {
|
|
fail(`DEVELOPMENT.md must document: ${phrase}`)
|
|
}
|
|
}
|
|
|
|
const chatSessionsDoc = await readText('docs/cli-chat-sessions.md')
|
|
for (const phrase of [
|
|
'最后重建时间',
|
|
'维护要求',
|
|
'最近链路变更记录',
|
|
'docs/chat-chain-changes/',
|
|
'每个 PR 一个变更片段',
|
|
'packages/server/src/services/hermes/agent-bridge/',
|
|
'packages/server/src/services/hermes/group-chat/',
|
|
'packages/server/src/lib/context-compressor/',
|
|
'任何改动都算 Chat 链路改动',
|
|
]) {
|
|
if (!chatSessionsDoc.includes(phrase)) {
|
|
fail(`docs/cli-chat-sessions.md must document chat chain maintenance rule: ${phrase}`)
|
|
}
|
|
}
|
|
|
|
const changedFiles = changedFilesFromGit()
|
|
const changedChatChainFiles = changedFiles.filter(
|
|
file => !isChatChainChangeFragment(file)
|
|
&& file !== 'docs/chat-chain-changes/README.md'
|
|
&& file !== 'docs/cli-chat-sessions.md'
|
|
&& isChatSessionChainFile(file),
|
|
)
|
|
const changedChatChainFragments = changedFiles.filter(isChatChainChangeFragment)
|
|
if (changedChatChainFiles.length > 0 && changedChatChainFragments.length === 0) {
|
|
fail(
|
|
[
|
|
'Chat session chain changed without adding a docs/chat-chain-changes/*.md fragment.',
|
|
'Add one fragment with date, PR/commit, touched feature, and behavior impact.',
|
|
`Changed chain files: ${changedChatChainFiles.join(', ')}`,
|
|
].join(' '),
|
|
)
|
|
}
|
|
for (const file of changedChatChainFragments) {
|
|
if (!existsSync(path.join(root, file))) {
|
|
fail(`Chat chain change fragment was removed instead of added/updated: ${file}`)
|
|
continue
|
|
}
|
|
const fragment = await readText(file)
|
|
for (const marker of ['date:', 'feature:', 'impact:']) {
|
|
if (!fragment.includes(marker)) {
|
|
fail(`Chat chain change fragment ${file} must include frontmatter field: ${marker}`)
|
|
}
|
|
}
|
|
if (!fragment.includes('pr:') && !fragment.includes('commit:')) {
|
|
fail(`Chat chain change fragment ${file} must include either pr: or commit:`)
|
|
}
|
|
}
|
|
|
|
// --- Legacy brand scan -------------------------------------------------
|
|
// The project was renamed from ekko / hermes-web-ui / hermes-studio to
|
|
// hermes-ui. A few legacy identifiers are deliberately preserved so existing
|
|
// deployments keep working; see ALLOWED_LEGACY_BRAND and
|
|
// GLOBAL_ALLOWED_PATTERNS below for the reason each one survives.
|
|
const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i, /hermes-studio/i]
|
|
|
|
const SCAN_ROOTS = [
|
|
'packages/client/src',
|
|
'packages/server/src',
|
|
'packages/skills',
|
|
'tests',
|
|
'scripts',
|
|
'bin',
|
|
'.github',
|
|
]
|
|
const SCAN_FILES = [
|
|
'package.json',
|
|
'Dockerfile',
|
|
'docker-compose.yml',
|
|
'AGENTS.md',
|
|
'ARCHITECTURE.md',
|
|
'DEVELOPMENT.md',
|
|
'README.md',
|
|
'README_zh.md',
|
|
]
|
|
const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py'])
|
|
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__'])
|
|
// These files necessarily spell out the patterns and allow-list entries they check.
|
|
// tests/server/no-legacy-brand.test.ts is the vitest mirror of this scan: it
|
|
// re-declares the same patterns and the same allow-list, so it would otherwise
|
|
// report itself on every line.
|
|
const SCAN_SKIP_FILES = new Set([
|
|
'scripts/harness-check.mjs',
|
|
'tests/server/no-legacy-brand.test.ts',
|
|
])
|
|
|
|
// Every entry must state why the legacy string is still allowed. An entry with
|
|
// a `pattern` exempts only the lines matching it, so a new violation elsewhere
|
|
// in the same file is still reported; omit `pattern` only when every legacy
|
|
// string in the file is intentional.
|
|
const ALLOWED_LEGACY_BRAND = [
|
|
{
|
|
file: 'AGENTS.md',
|
|
pattern: /hermes-studio\.ai|global-agent relay|not affiliated/,
|
|
reason: 'Red Lines warn agents not to strip the load-bearing upstream relay address, so the doc has to name it',
|
|
},
|
|
{
|
|
file: 'packages/server/src/db/index.ts',
|
|
reason: 'DB filenames predate the rename; renamed files would orphan data in ~/.hermes-web-ui',
|
|
},
|
|
{
|
|
file: 'bin/hermes-ui.mjs',
|
|
reason: 'CLI reads the same pre-rename DB filename as packages/server/src/db/index.ts',
|
|
},
|
|
{
|
|
file: 'packages/server/src/config.ts',
|
|
pattern: /url: 'https:\/\/api\.hermes-studio\.ai'/,
|
|
reason: 'remoteRelay is the live external global-agent relay service, not the product brand',
|
|
},
|
|
{
|
|
file: 'packages/server/src/middleware/user-auth.ts',
|
|
reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login',
|
|
},
|
|
{
|
|
file: 'packages/server/src/services/hermes/skill-injector.ts',
|
|
reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected',
|
|
},
|
|
{
|
|
file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts',
|
|
reason: 'legacy MCP server/command names are detected to clean up old on-disk config',
|
|
},
|
|
{
|
|
file: 'packages/server/src/services/hermes/agent-bridge/manager.ts',
|
|
pattern: /HERMES_OPENROUTER_APP_REFERER: 'https:\/\/hermes-studio\.ai'/,
|
|
reason: 'OpenRouter attribution referer is the upstream app identity, not the product brand',
|
|
},
|
|
{
|
|
file: 'packages/client/src/components/layout/AppSidebar.vue',
|
|
pattern: /href="https:\/\/hermes-studio\.ai\/"/,
|
|
reason: 'sidebar footer links to the upstream product website, not the product brand',
|
|
},
|
|
{
|
|
file: 'tests/server/studio-mcp-autoinject.test.ts',
|
|
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
|
|
},
|
|
{
|
|
file: 'tests/server/mcu-login-controller.test.ts',
|
|
pattern: /const remoteRelayUrl = 'https:\/\/api\.hermes-studio\.ai'/,
|
|
reason: 'asserts the remoteRelay base URL configured in packages/server/src/config.ts',
|
|
},
|
|
{
|
|
file: 'tests/server/agent-bridge-manager.test.ts',
|
|
pattern: /HERMES_OPENROUTER_APP_REFERER\)\.toBe\('https:\/\/hermes-studio\.ai'\)/,
|
|
reason: 'asserts the OpenRouter referer configured in agent-bridge/manager.ts',
|
|
},
|
|
{
|
|
file: 'tests/server/run-chat-bridge-final-context.test.ts',
|
|
pattern: /not\.toContain\('mcp__hermes-studio__'\)/,
|
|
reason: 'asserts the legacy MCP tool prefix does not leak into bridge instructions',
|
|
},
|
|
{
|
|
file: 'tests/server/cli-port-detection.test.ts',
|
|
reason: 'temp dir names and the preserved hermes-web-ui.db filename',
|
|
},
|
|
{
|
|
file: 'tests/server/agent-bridge-python-concurrency.test.ts',
|
|
reason: 'asserts preview socket path derived from the preview endpoint env var',
|
|
},
|
|
{
|
|
file: 'packages/server/src/controllers/hermes/sessions.ts',
|
|
reason: 'the visible-session-source check still lists coding_agent so sessions stored before Phase 003 stay listed',
|
|
},
|
|
{
|
|
file: 'packages/client/src/shared/session-display.ts',
|
|
reason: 'SOURCE_LABELS still maps the pre-Phase-003 coding_agent source so stored sessions render a label',
|
|
},
|
|
]
|
|
|
|
// Legacy identifiers that stay valid everywhere, because they are public
|
|
// deployment contracts or wire-protocol values rather than project branding.
|
|
const GLOBAL_ALLOWED_PATTERNS = [
|
|
/HERMES_WEB_UI[A-Z0-9_]*/,
|
|
/HERMES_WEBUI_STATE_DIR/,
|
|
/\.hermes-web-ui/,
|
|
/hermes_brightness/,
|
|
/hermes_style/,
|
|
/hermes_locale/,
|
|
// Global-agent relay socket role identifier. It is part of the wire contract
|
|
// with the relay service, so renaming it would break cross-version peers.
|
|
/role\s*(?::|===)\s*'hermes-studio'/,
|
|
]
|
|
|
|
async function collectScanFiles(target, out) {
|
|
const absolute = path.join(root, target)
|
|
const stat = await readdir(absolute, { withFileTypes: true }).catch(() => null)
|
|
if (!stat) return
|
|
for (const entry of stat) {
|
|
const relative = path.join(target, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (SCAN_SKIP_DIRS.has(entry.name)) continue
|
|
await collectScanFiles(relative, out)
|
|
} else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
|
out.push(relative)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isAllowedLegacyBrand(relativePath, line) {
|
|
if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true
|
|
const normalized = relativePath.split(path.sep).join('/')
|
|
// An allow-list entry may name a directory, in which case it covers its files.
|
|
return ALLOWED_LEGACY_BRAND.some(entry => {
|
|
if (normalized !== entry.file && !normalized.startsWith(`${entry.file}/`)) return false
|
|
return !entry.pattern || entry.pattern.test(line)
|
|
})
|
|
}
|
|
|
|
const scanFiles = []
|
|
for (const target of SCAN_ROOTS) await collectScanFiles(target, scanFiles)
|
|
for (const file of SCAN_FILES) {
|
|
if (existsSync(path.join(root, file))) scanFiles.push(file)
|
|
}
|
|
|
|
const brandHits = []
|
|
for (const relativePath of scanFiles) {
|
|
if (SCAN_SKIP_FILES.has(relativePath.split(path.sep).join('/'))) continue
|
|
const text = await readFile(path.join(root, relativePath), 'utf8')
|
|
const lines = text.split(/\r?\n/)
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index]
|
|
if (!LEGACY_BRAND_PATTERNS.some(pattern => pattern.test(line))) continue
|
|
if (isAllowedLegacyBrand(relativePath, line)) continue
|
|
brandHits.push(`${relativePath}:${index + 1}: ${line.trim()}`)
|
|
}
|
|
}
|
|
for (const hit of brandHits) {
|
|
fail(`Legacy brand string must be removed (ekko / hermes-web-ui / hermes-studio): ${hit}`)
|
|
}
|
|
|
|
// --- Retired runtime scan ----------------------------------------------
|
|
// Phase 003 removed the coding-agent and in-house agent runtimes. Every chat
|
|
// session now runs through the Hermes bridge. A few identifiers survive only
|
|
// so sessions, workflows, and history rows stored before Phase 003 keep
|
|
// loading; RETIRED_RUNTIME_ALLOWED records why each one stays.
|
|
const RETIRED_RUNTIME_PATTERNS = [
|
|
/\bcoding_agent\b/,
|
|
/\bcodingAgent[A-Za-z]/,
|
|
/\bCodingAgent[A-Za-z]/,
|
|
/\bekko-agent\b/,
|
|
/\bekkoAgent[A-Za-z]/,
|
|
/\bEkkoAgent[A-Za-z]/,
|
|
]
|
|
|
|
const RETIRED_RUNTIME_ALLOWED = [
|
|
{
|
|
file: 'packages/server/src/controllers/hermes/sessions.ts',
|
|
reason: 'isVisibleWebUiSessionSource still accepts the retired source so sessions stored before Phase 003 stay listed',
|
|
},
|
|
{
|
|
file: 'packages/client/src/shared/session-display.ts',
|
|
reason: 'SOURCE_LABELS still maps the retired source so stored sessions render a label',
|
|
},
|
|
]
|
|
|
|
function isAllowedRetiredRuntime(relativePath, line) {
|
|
// Comments explain why the retired identifier still exists; only code hits matter.
|
|
const trimmed = line.trim()
|
|
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return true
|
|
const normalized = relativePath.split(path.sep).join('/')
|
|
return RETIRED_RUNTIME_ALLOWED.some(
|
|
entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`),
|
|
)
|
|
}
|
|
|
|
const retiredHits = []
|
|
for (const relativePath of scanFiles) {
|
|
const normalized = relativePath.split(path.sep).join('/')
|
|
if (SCAN_SKIP_FILES.has(normalized)) continue
|
|
if (normalized.startsWith('docs/')) continue
|
|
const text = await readFile(path.join(root, relativePath), 'utf8')
|
|
const lines = text.split(/\r?\n/)
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index]
|
|
if (!RETIRED_RUNTIME_PATTERNS.some(pattern => pattern.test(line))) continue
|
|
if (isAllowedRetiredRuntime(relativePath, line)) continue
|
|
retiredHits.push(`${relativePath}:${index + 1}: ${line.trim()}`)
|
|
}
|
|
}
|
|
for (const hit of retiredHits) {
|
|
fail(`Retired runtime identifier must be removed (coding-agent / ekko-agent): ${hit}`)
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('Harness check failed:')
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('Harness check passed')
|