Phase 008: close the test and CI loop around the migration

Adds the checks that make the constraints from Phase 001-007 mechanical
instead of tribal knowledge, and clears the legacy brand text that was still
reachable at runtime.

New red-line tests:
  - no-legacy-brand mirrors the harness scan under vitest, and asserts its
    allow-list has not drifted from harness-check.mjs
  - hermes-db-readonly classifies every new DatabaseSync call site by
    following the path it opens, and fails if a state.db handle drops
    readOnly. It also pins the one legitimately writable database.
  - i18n-parity compares the raw zh/en locale objects both ways. The
    existing coverage test merged en into zh at import time, so a key
    missing from zh could never fail there.

harness:check gains the four missing gates from the phase brief - retired
package directories, retired package scripts, a single UI library, and the
required root files. Each was verified to fail by mutating the repo.

The online-update channel had hardcoded upstream URLs. It now reads
HERMES_WEB_UI_VERSION_MANIFEST_URL and friends and degrades with a named
error, because a reimplementation has nowhere to pull releases from.

The remaining legacy strings are deliberate and each carries a reason in the
allow-list: the global-agent relay address and the OpenRouter referer are
load-bearing, the socket role identifier is a wire contract, and the pre-rename
DB filenames would orphan existing deployments if changed. AGENTS.md now says
so explicitly, so a future agent does not "clean them up".

Also removes a dead electron mock alias, an i18n exemption that had stopped
exempting anything, and a teardown race in plugins-view.test.ts where a-spin
animation frames outlived the jsdom environment.

Verified self-contained: with reference_code/ moved aside, harness:check,
build, test and test:e2e all pass, so the directory can now be deleted.
This commit is contained in:
2026-10-19 00:23:19 +08:00
parent 836d0228d2
commit cc644c59f6
17 changed files with 1135 additions and 56 deletions
@@ -2,7 +2,7 @@
* Tests that handle-bridge-run properly writes ended_at / end_reason
* to the session DB when a run terminates (normal or error).
*
* Relates to: https://github.com/EKKOLearnAI/hermes-studio/issues/1998
* Relates to: upstream issue #1998 (session ended_at / end_reason not persisted)
*/
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+320
View File
@@ -0,0 +1,320 @@
import { readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* 红线:Hermes Agent 的 `state.db` 对 Node 侧是**只读**的(AGENTS.md Red Lines)。
*
* 现状(Phase 008 §3.2 调研结论):
* - `packages/server/src/db/hermes/sessions-db.ts` —— 7 处 `new DatabaseSync(...)`,
* 路径来自同文件的 `sessionDbPath()` / `sessionDbPathForProfile()`;
* - `packages/server/src/db/hermes/conversations-db.ts` —— 1 处,路径来自
* 同文件的 `conversationDbPath()`;
* - 两者全部带 `readOnly: true`。
*
* 判断「这个 DatabaseSync 打开的是不是 state.db」的做法(不使用「state.db 字符串
* 出现次数」这种脆判定):
* 1. 先在**同一个文件**里找出所有「路径构造函数」——函数体里出现 `'state.db'`
* 字面量的函数(当前是 sessionDbPath / sessionDbPathForProfile / conversationDbPath);
* 2. 对每个 `new DatabaseSync(` 调用点取第一个实参,判定它的来源:
* 实参里出现 `'state.db'` 字面量 → state.db;
* 实参里调用了上述某个路径构造函数 → state.db(覆盖
* `profile ? sessionDbPathForProfile(profile) : sessionDbPath()` 这类三元表达式);
* 实参是裸标识符 → 在同文件里找到它的 `const/let/var` 声明并**递归**解析
* (sessions-db.ts 的 `const dbPath = ...` 就是这种形态),用 visited 集合防环;
* 3. 解析不出来 → 归为 `unknown` 并**直接判失败**,要求显式分类。
*
* 第 3 步是关键:宁可误报也不漏报。这样将来有人把路径来源改成一个新形态
* (比如从配置读路径),测试会立刻失败并要求写明该调用点的归类,而不是
* 静悄悄地跳过检查、让红线形同虚设。
*/
const SERVER_SRC = 'packages/server/src'
/**
* 唯一允许可写的 SQLite 打开点:Web UI 自己的库。
*
* 豁免原因:`packages/server/src/db/index.ts` 打开的是 Web UI 自有的 SQLite 库
* (文件名早于 hermes-ui 改名,见该文件顶部注释),保存的是本项目自己的会话、
* 设置、设备等数据,**必须**可写;它与 Hermes Agent 的 state.db 无关。
* 这也是全仓唯一被允许写开的库——新增豁免前请先确认新库不是 Hermes 侧的数据。
*/
const WRITABLE_EXEMPT_FILES = new Set(['packages/server/src/db/index.ts'])
type Classification = 'state-db' | 'own-db' | 'unknown'
type CallSite = {
file: string
line: number
/** 第一个实参原文,用于报错定位 */
pathExpr: string
/** 第二个实参(options)原文 */
optionsExpr: string
classification: Classification
}
const toRelative = (file: string) => file.split('\\').join('/')
function listSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
// Python 桥接层是 vendored 只读代码,且不直接用 sqlite
if (entry.name === 'python' || entry.name === '__pycache__') continue
listSourceFiles(full, out)
} else if (full.endsWith('.ts')) {
out.push(toRelative(full))
}
}
return out
}
const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
/** 从 openIndex 处的括号开始做配对,返回配对括号之后的下标 */
function matchBracket(source: string, openIndex: number): number {
const close = OPENERS[source[openIndex]]
let depth = 0
for (let i = openIndex; i < source.length; i += 1) {
if (source[i] === source[openIndex]) depth += 1
else if (source[i] === close) {
depth -= 1
if (depth === 0) return i
}
}
return -1
}
/**
* 从 start 处读出一个「实参 / 初始化表达式」:在括号深度为 0 的位置遇到
* 逗号、分号或换行即停止,因此既能吃下 `dbPath, { ... }` 里的 `dbPath`,
* 也能吃下跨行的三元表达式。
*/
function readExpression(source: string, start: number): string {
let i = start
for (; i < source.length; i += 1) {
const ch = source[i]
if (OPENERS[ch]) {
const close = matchBracket(source, i)
// 括号不配对(理论上不会发生)时保守退出,避免整文件扫描被拖死
if (close === -1) return source.slice(start, i)
i = close
continue
}
// 括号外的逗号 / 分号 / 换行即表达式结束
if (ch === ',' || ch === ';' || ch === '\n') break
}
return source.slice(start, i).trim()
}
/** 按顶层逗号切分实参列表(忽略括号内的逗号) */
function splitArgs(source: string): string[] {
const args: string[] = []
let current = ''
let i = 0
while (i < source.length) {
const ch = source[i]
if (OPENERS[ch]) {
const close = matchBracket(source, i)
if (close === -1) break
current += source.slice(i, close + 1)
i = close + 1
continue
}
if (ch === ',') {
args.push(current)
current = ''
i += 1
continue
}
current += ch
i += 1
}
if (current.trim()) args.push(current)
return args.map(arg => arg.trim())
}
/** 收集同文件里所有「路径构造函数」:函数体中出现 'state.db' 字面量的函数名 */
function collectStateDbPathFunctions(source: string): Set<string> {
const names = new Set<string>()
const declRe = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g
for (const match of source.matchAll(declRe)) {
const braceIndex = source.indexOf('{', match.index + match[0].length)
if (braceIndex === -1) continue
const end = matchBracket(source, braceIndex)
if (end === -1) continue
if (/'state\.db'|"state\.db"/.test(source.slice(braceIndex, end))) names.add(match[1])
}
return names
}
/** 收集同文件里所有 const/let/var 声明 -> 初始化表达式原文 */
function collectBindings(source: string): Map<string, string> {
const bindings = new Map<string, string>()
const declRe = /(?:^|[\s;{(])(?:const|let|var)\s+(\w+)\s*(?::[^=\n]+)?=\s*/g
for (const match of source.matchAll(declRe)) {
const expr = readExpression(source, match.index + match[0].length)
if (expr) bindings.set(match[1], expr)
}
return bindings
}
function classify(
pathExpr: string,
stateDbFunctions: Set<string>,
bindings: Map<string, string>,
visited: Set<string>,
): Classification {
// 1. 实参直接写了 state.db 路径
if (/'state\.db'|"state\.db"/.test(pathExpr)) return 'state-db'
// 2. 实参调用了同文件的某个 state.db 路径构造函数(含三元表达式等组合形态)
for (const name of stateDbFunctions) {
if (new RegExp(`\\b${name}\\s*\\(`).test(pathExpr)) return 'state-db'
}
// 3. 实参是裸标识符:回到同文件的声明处递归解析
if (/^\w+$/.test(pathExpr)) {
if (visited.has(pathExpr)) return 'unknown'
visited.add(pathExpr)
const initializer = bindings.get(pathExpr)
if (initializer === undefined) return 'unknown'
return classify(initializer, stateDbFunctions, bindings, visited)
}
return 'unknown'
}
function findCallSites(): CallSite[] {
const sites: CallSite[] = []
for (const file of listSourceFiles(SERVER_SRC)) {
const source = readFileSync(file, 'utf8')
const stateDbFunctions = collectStateDbPathFunctions(source)
const bindings = collectBindings(source)
for (const match of source.matchAll(/new\s+DatabaseSync\s*\(/g)) {
const openIndex = match.index + match[0].length - 1
const closeIndex = matchBracket(source, openIndex)
const args = closeIndex === -1 ? [] : splitArgs(source.slice(openIndex + 1, closeIndex))
sites.push({
file,
line: source.slice(0, match.index).split('\n').length,
pathExpr: args[0] ?? '',
optionsExpr: args[1] ?? '',
// 豁免文件不参与 state.db 归类:它打开的是自有库,恒为可写
classification: WRITABLE_EXEMPT_FILES.has(file)
? 'own-db'
: classify(args[0] ?? '', stateDbFunctions, bindings, new Set()),
})
}
}
return sites
}
function describeSite(site: CallSite): string {
return `${site.file}:${site.line} new DatabaseSync(${site.pathExpr}${
site.optionsExpr ? `, ${site.optionsExpr}` : ''
})`
}
describe('Hermes state.db stays read-only from Node', () => {
const callSites = findCallSites()
const stateDbSites = callSites.filter(site => site.classification === 'state-db')
const ownDbSites = callSites.filter(site => site.classification === 'own-db')
const unknownSites = callSites.filter(site => site.classification === 'unknown')
it('finds every DatabaseSync call site it is meant to guard', () => {
// 防止扫描规则本身失效(比如 collectBindings / matchBracket 改坏)导致整文件空转
expect(callSites.length).toBeGreaterThanOrEqual(9)
expect(stateDbSites.length).toBeGreaterThanOrEqual(8)
expect(
unknownSites.map(describeSite),
'无法判定数据库路径来源,因此无法确认是否只读。请把该调用点的路径来源改成'
+ '本文件里的 state.db 路径构造函数,或在写明原因后加入 WRITABLE_EXEMPT_FILES。',
).toEqual([])
})
it('opens every state.db handle with readOnly: true', () => {
const writable = stateDbSites
.filter(site => !/readOnly\s*:\s*true/.test(site.optionsExpr))
.map(describeSite)
expect(
writable,
writable.length === 0 ? '' : [
'以下调用打开了 Hermes 的 state.db 但没有 readOnly: true。',
'state.db 归 Hermes Agent 所有,Node 侧只能读;',
'打开方式应与 sessions-db.ts 现有写法一致:',
' new DatabaseSync(<path>, { open: true, readOnly: true })',
'需要写入的数据请落到 Web UI 自有库,不要写进 Hermes 的库。',
].join('\n'),
).toEqual([])
})
it('exempts only the Web UI own database from read-only', () => {
// 自有库必须仍然是可写的:把它改成 readOnly 会让 Web UI 自己的数据写不进去,
// 那说明豁免的前提(自有库可写)已经不成立,需要重新审视这条豁免。
expect(ownDbSites.map(site => site.file)).toEqual([...WRITABLE_EXEMPT_FILES])
for (const site of ownDbSites) {
expect(
site.optionsExpr,
`${site.file} 是自有库豁免点,必须保持可写(不要加 readOnly: true)`,
).not.toMatch(/readOnly\s*:\s*true/)
}
})
it('never reaches for the retired better-sqlite3 API', () => {
// 全仓已统一到 node:sqlite 的 DatabaseSync。这几条是防回退:
// better-sqlite3 的 `new Database(...)` 默认就是可写打开,且需要再调 .open(),
// 一旦回退,state.db 就会被无声地以读写模式打开。
const offenders: string[] = []
for (const file of listSourceFiles(SERVER_SRC)) {
const source = readFileSync(file, 'utf8')
source.split(/\r?\n/).forEach((line, index) => {
if (/\bnew\s+Database\s*\(/.test(line)) {
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 new Database(...): ${line.trim()}`)
}
if (/better-sqlite3/.test(line)) {
offenders.push(`${file}:${index + 1} 引用了 better-sqlite3: ${line.trim()}`)
}
if (/\.open\s*\(/.test(line)) {
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 .open(): ${line.trim()}`)
}
})
}
expect(
offenders,
offenders.length === 0 ? '' : [
'数据库访问必须统一走 node:sqlite 的 DatabaseSync:',
" import { DatabaseSync } from 'node:sqlite'",
'打开 Hermes 的 state.db 时必须带 { open: true, readOnly: true }。',
].join('\n'),
).toEqual([])
})
it('keeps the state.db path constructors in sync with the red line', () => {
// 只读红线的前提是「路径构造函数仍然指向 state.db」。如果有人把 state.db 的
// 路径改成可写来源或改名,这里会先一步报出来,避免红线测试被悄悄架空。
const found = new Set<string>()
for (const file of listSourceFiles(SERVER_SRC)) {
for (const name of collectStateDbPathFunctions(readFileSync(file, 'utf8'))) {
found.add(`${file}#${name}`)
}
}
expect(
[...found].sort(),
'state.db 路径构造函数集合变了:如果新增了构造函数,请确认它同样只被以 '
+ 'readOnly: true 打开;如果是被删掉/改名了,请同步更新本测试。',
).toEqual([
'packages/server/src/db/hermes/conversations-db.ts#conversationDbPath',
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPath',
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPathForProfile',
])
})
})
+312
View File
@@ -0,0 +1,312 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* 旧品牌清理的测试层红线(Phase 008 §3)。
*
* `scripts/harness-check.mjs` 里已经有一份静态版扫描(CI 的 `npm run
* harness:check` 会跑)。这里再写一份 vitest 版不是为了重复,而是为了在
* `npm run test` 里给出**逐行、可定位**的失败信息:harness 只会打印
* `path:line: 内容`,而这里会把命中的具体 pattern、文件、以及修复建议
* 一起列出来,方便维护者直接定位。
*
* 扫描规则(pattern / 目录 / 扩展名 / 白名单粒度)必须与
* `scripts/harness-check.mjs` 的 LEGACY_BRAND_PATTERNS + SCAN_* +
* ALLOWED_LEGACY_BRAND + GLOBAL_ALLOWED_PATTERNS 保持一致,漂移由本文件
* 末尾的 "uses the same allow-list as scripts/harness-check.mjs" 用例机械拦住。
*/
const HARNESS_SCRIPT = 'scripts/harness-check.mjs'
// 与 harness-check.mjs 的 LEGACY_BRAND_PATTERNS 一致:ekko / hermes-web-ui / hermes-studio
const LEGACY_BRAND_PATTERNS = [
{ name: 'ekko', pattern: /\bekko\b/i },
{ name: 'hermes-web-ui', pattern: /hermes-web-ui/i },
{ name: 'hermes-studio', pattern: /hermes-studio/i },
]
// 与 harness-check.mjs 的 SCAN_ROOTS 覆盖范围一致(harness 按包拆开,
// 这里用 packages 根目录 + 其余相同取值;packages 下当前只有 client/server/skills)
const SCAN_ROOTS = ['packages', 'tests', 'scripts', 'bin', '.github']
// 与 harness-check.mjs 的 SCAN_EXTENSIONS 一致
const SCAN_EXTENSIONS = new Set([
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py',
])
// 与 harness-check.mjs 的 SCAN_SKIP_DIRS 一致
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__'])
// 与 harness-check.mjs 的 SCAN_SKIP_FILES 一致。
// 扫描器自己必然要写出被禁字符串,所以它和本文件都必须跳过自己。
const SCAN_SKIP_FILES = new Set([
'scripts/harness-check.mjs',
'tests/server/no-legacy-brand.test.ts',
])
/**
* 与 harness-check.mjs 的 ALLOWED_LEGACY_BRAND 逐条对应。
*
* 每条豁免必须写清原因(AGENTS.md 要求):改动这份列表前先确认那个旧字符串
* 仍然是「部署兼容 / 上游身份 / 历史数据」而不是漏网的品牌残留。
*
* 粒度与 harness 一致:**两级**。
* - 带 `pattern`:只豁免匹配该 pattern 的行,同文件内新增的违规仍会被报出来;
* - 不带 `pattern`(pattern 为 undefined):整文件豁免,仅在文件里每一处旧
* 字符串都是有意保留时才允许。
*/
type AllowEntry = { file: string; pattern?: RegExp; reason: string }
const ALLOWED_LEGACY_BRAND: AllowEntry[] = [
{
// 原因:AGENTS.md 的 Red Lines 要提醒 agent 别把承载功能的上游中继地址当残留清掉,
// 所以文档必须写出该地址
file: 'AGENTS.md',
pattern: /hermes-studio\.ai|global-agent relay|not affiliated/,
reason: 'The Red Lines warn agents not to strip the load-bearing relay address, so the doc names it',
},
{
// 原因:DB 文件名早于改名;改名会让 ~/.hermes-web-ui 下已有的部署数据变成孤儿
file: 'packages/server/src/db/index.ts',
reason: 'DB file names predate the rename; renaming them would orphan existing deployments',
},
{
// 原因:CLI 必须读同一个改名前的 DB 文件名,否则和服务端对不上
file: 'bin/hermes-ui.mjs',
reason: 'CLI resolves the same pre-rename DB filename as packages/server/src/db/index.ts',
},
{
// 原因:remoteRelay 是外部 global-agent 中继服务的线上地址,不是产品品牌
file: 'packages/server/src/config.ts',
pattern: /url: 'https:\/\/api\.hermes-studio\.ai'/,
reason: 'remoteRelay is the live external relay endpoint, not the product brand',
},
{
// 原因:旧 JWT audience 必须继续被接受,否则升级会强制所有用户重新登录
file: 'packages/server/src/middleware/user-auth.ts',
reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login',
},
{
// 原因:改名前写下的 manifest owner 必须继续被识别,否则托管 skills 会被重复注入
file: 'packages/server/src/services/hermes/skill-injector.ts',
reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected',
},
{
// 原因:这些旧 MCP server / command 名是被**用来清理**存量磁盘配置的识别标记
file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts',
reason: 'legacy MCP names are the markers used to clean up old on-disk config',
},
{
// 原因:OpenRouter 的 attribution referer 是上游 app 身份,不是产品品牌
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',
},
{
// 原因:侧边栏页脚指向上游产品官网,不是产品品牌
file: 'packages/client/src/components/layout/AppSidebar.vue',
pattern: /href="https:\/\/hermes-studio\.ai\/"/,
reason: 'sidebar footer links to the upstream product website',
},
{
// 原因:购买徽章指向托管 ESP32 活动的上游文档站
file: 'packages/client/src/components/layout/SettingsCircuitBadge.vue',
pattern: /const purchaseUrl = 'https:\/\/hermes-studio\.ai\/docs\//,
reason: 'purchase badge links to the upstream docs site hosting the ESP32 offer',
},
{
// 原因:断言旧 MCP 条目会从存量磁盘配置里被清掉
file: 'tests/server/studio-mcp-autoinject.test.ts',
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
},
{
// 原因:断言 packages/server/src/config.ts 里配置的 remoteRelay base URL
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',
},
{
// 原因:断言 agent-bridge/manager.ts 里配置的 OpenRouter referer
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',
},
{
// 原因:断言旧 MCP 工具前缀不会泄漏进 bridge instructions
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',
},
{
// 原因:临时目录名 + 改名前保留的 DB 文件名
file: 'tests/server/cli-port-detection.test.ts',
reason: 'temp dir names and the preserved pre-rename DB filename',
},
{
// 原因:断言由 preview endpoint 环境变量推导出的 preview socket 路径
file: 'tests/server/agent-bridge-python-concurrency.test.ts',
reason: 'asserts preview socket path derived from the preview endpoint env var',
},
{
// 原因:可见来源检查仍需列出改名前的来源,Phase 003 之前存的会话才不会被隐藏
file: 'packages/server/src/controllers/hermes/sessions.ts',
reason: 'visible-session-source check still lists the pre-rename source so old sessions stay listed',
},
{
// 原因:SOURCE_LABELS 仍映射改名前的来源,历史会话才有标签可渲染
file: 'packages/client/src/shared/session-display.ts',
reason: 'SOURCE_LABELS still maps the pre-rename source so stored sessions render a label',
},
]
/**
* 与 harness-check.mjs 的 GLOBAL_ALLOWED_PATTERNS 一致:这些标识符是公开的部署
* 契约或线上协议取值,而不是项目品牌,任何文件里出现都合法。
*/
const GLOBAL_ALLOWED_PATTERNS: RegExp[] = [
/HERMES_WEB_UI[A-Z0-9_]*/,
/HERMES_WEBUI_STATE_DIR/,
/\.hermes-web-ui/,
/hermes_brightness/,
/hermes_style/,
/hermes_locale/,
// global-agent 中继的 socket role 标识符,属于与中继服务之间的线上契约,改名会打断跨版本互通
/role\s*(?::|===)\s*'hermes-studio'/,
]
const toRelative = (file: string) => file.split('\\').join('/')
function collectFiles(target: string, out: string[] = []): string[] {
if (!existsSync(target)) return out
for (const entry of readdirSync(target)) {
const full = join(target, entry)
if (statSync(full).isDirectory()) {
if (SCAN_SKIP_DIRS.has(entry.name)) continue
collectFiles(full, out)
} else {
const dot = full.lastIndexOf('.')
if (dot !== -1 && SCAN_EXTENSIONS.has(full.slice(dot))) out.push(toRelative(full))
}
}
return out
}
function isAllowed(relativePath: string, line: string): boolean {
if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true
return ALLOWED_LEGACY_BRAND.some((entry) => {
// 与 harness 一致:条目可以写目录名,此时覆盖其下所有文件
if (relativePath !== entry.file && !relativePath.startsWith(`${entry.file}/`)) return false
return !entry.pattern || entry.pattern.test(line)
})
}
type Violation = { file: string; line: number; brand: string; text: string }
function scanLegacyBrand(files: string[]): Violation[] {
const violations: Violation[] = []
for (const file of files) {
if (SCAN_SKIP_FILES.has(file)) continue
const lines = readFileSync(file, 'utf8').split(/\r?\n/)
for (const [index, line] of lines.entries()) {
const brand = LEGACY_BRAND_PATTERNS.find(entry => entry.pattern.test(line))
if (!brand || isAllowed(file, line)) continue
violations.push({ file, line: index + 1, brand: brand.name, text: line.trim() })
}
}
return violations
}
function describeViolations(violations: Violation[]): string {
const detail = violations
.map(v => ` ${v.file}:${v.line} 命中旧品牌 "${v.brand}"\n ${v.text}`)
.join('\n')
return `${violations.length} 处旧品牌字符串未清理:\n${detail}\n\n`
+ '修复方式:把该处改成 hermes-ui 的写法;如果这处旧字符串是部署兼容 / 上游身份 /'
+ '历史数据所必需,就把它加进 scripts/harness-check.mjs 的 ALLOWED_LEGACY_BRAND'
+ '(带 pattern 的条目只豁免匹配行,不带 pattern 的条目豁免整文件),'
+ '并同步本文件的 ALLOWED_LEGACY_BRAND,否则 drift 用例会失败。'
}
describe('legacy brand removal (ekko / hermes-web-ui / hermes-studio)', () => {
const scannedFiles = SCAN_ROOTS.flatMap(root => collectFiles(root))
it('has a non-empty scan surface', () => {
// 防止扫描目录写错导致本文件整体空转、永远绿灯
expect(scannedFiles.length).toBeGreaterThan(200)
for (const root of SCAN_ROOTS) {
expect(existsSync(root), `${root} 必须存在,否则品牌扫描覆盖面被悄悄缩小`).toBe(true)
}
})
it('contains no legacy brand string outside the allow-list', () => {
const violations = scanLegacyBrand(scannedFiles)
expect(violations, describeViolations(violations)).toEqual([])
})
it('keeps every allow-list entry justified and free of stale paths', () => {
for (const entry of ALLOWED_LEGACY_BRAND) {
expect(entry.reason.trim(), `${entry.file} 的豁免必须写明原因`).not.toBe('')
expect(
existsSync(entry.file),
`白名单条目 ${entry.file} 指向的文件已不存在,请删除该豁免`
+ '(否则将来新建同名文件时会白白掩盖违规)',
).toBe(true)
}
})
it('uses the same allow-list as scripts/harness-check.mjs', () => {
// 防漂移守卫:本文件的豁免是 harness-check.mjs 的**副本**。
// 不从 harness import 那份清单,是因为 harness-check.mjs 是一支顶层即执行的
// 脚本(import 它会连带跑完整检查、读 git diff、甚至 process.exit),
// 为了拿一份常量把它重构成模块代价过大;改成从源码里把两份清单抽出来做等价性
// 比对,任何一侧增删条目都会在这里失败。
const harness = readFileSync(HARNESS_SCRIPT, 'utf8')
const readArray = (name: string): string => {
const marker = `const ${name} = [`
const start = harness.indexOf(marker)
expect(start, `${HARNESS_SCRIPT} 里找不到 ${name},本测试需要同步更新`).toBeGreaterThan(-1)
const end = harness.indexOf('\n]\n', start)
expect(end, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).toBeGreaterThan(start)
return harness.slice(start + marker.length - 1, end + 2)
}
// harness 里不带 pattern 的条目 = 整文件豁免,这里统一记成 '<file>'
const WHOLE_FILE = '<file>'
const entryRe = /\{\s*file:\s*'([^']+)'(?:,\s*pattern:\s*(\/(?:[^/\\\n]|\\.)*\/[a-z]*))?,\s*reason:\s*'([^']*)'/g
const harnessEntries: { file: string; pattern: string }[] = []
for (const match of readArray('ALLOWED_LEGACY_BRAND').matchAll(entryRe)) {
// 去掉字面量两侧的斜杠与 flag,只留 source,才能和 RegExp.prototype.source 比较
const pattern = match[2] ? match[2].replace(/^\/(.*)\/[a-z]*$/, '$1') : WHOLE_FILE
harnessEntries.push({ file: match[1], pattern })
expect(match[3].trim(), `harness 的 ${match[1]} 豁免缺少 reason`).not.toBe('')
}
const globalRe = /^[ \t]*(\/(?:[^/\\\n]|\\.)*\/[a-z]*),[ \t]*$/gm
const harnessGlobals = [...readArray('GLOBAL_ALLOWED_PATTERNS').matchAll(globalRe)]
.map(m => m[1].replace(/^\/(.*)\/[a-z]*$/, '$1'))
expect(harnessEntries.length, `未能从 ${HARNESS_SCRIPT} 解析出 ALLOWED_LEGACY_BRAND`).toBeGreaterThan(0)
expect(harnessGlobals.length, `未能从 ${HARNESS_SCRIPT} 解析出 GLOBAL_ALLOWED_PATTERNS`).toBeGreaterThan(0)
const sortKey = (entry: { file: string; pattern: string }) => `${entry.file}\u0000${entry.pattern}`
const localEntries = ALLOWED_LEGACY_BRAND.map(e => ({
file: e.file,
pattern: e.pattern ? e.pattern.source : WHOLE_FILE,
}))
expect(
localEntries.map(sortKey).sort(),
`ALLOWED_LEGACY_BRAND 已与 ${HARNESS_SCRIPT} 漂移,请把两边改成一致`,
).toEqual(harnessEntries.map(sortKey).sort())
expect(
GLOBAL_ALLOWED_PATTERNS.map(p => p.source).sort(),
`GLOBAL_ALLOWED_PATTERNS 已与 ${HARNESS_SCRIPT} 漂移,请把两边改成一致`,
).toEqual(harnessGlobals.sort())
})
})