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', }, { // 原因:断言旧 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) // 行尾不能写死 '\n]':Windows 上 checkout 会把 LF 转成 CRLF // (core.autocrlf=true),写死 LF 的话这个测试只会在 Windows 上红, // Linux CI 上却是绿的。仓库的 .gitattributes 已把行尾统一成 LF,但 // 在它被加进来之前 checkout 过的工作区仍留着 CRLF,所以这里两种都要认。 const rest = harness.slice(start) const closeMatch = /\r?\n\]/.exec(rest) expect(closeMatch, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).not.toBeNull() const end = start + closeMatch!.index return harness.slice(start + marker.length - 1, end + 2) } // harness 里不带 pattern 的条目 = 整文件豁免,这里统一记成 '' const WHOLE_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()) }) })