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:
+129
-19
@@ -107,6 +107,14 @@ for (const file of [
|
||||
'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',
|
||||
]) {
|
||||
requireFile(file)
|
||||
}
|
||||
@@ -124,6 +132,24 @@ for (const dir of [
|
||||
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) {
|
||||
@@ -156,6 +182,48 @@ for (const scriptName of [
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止脚本: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',
|
||||
@@ -228,10 +296,11 @@ for (const file of changedChatChainFragments) {
|
||||
}
|
||||
|
||||
// --- Legacy brand scan -------------------------------------------------
|
||||
// The project was renamed from ekko / hermes-web-ui to hermes-ui. A few
|
||||
// legacy identifiers are deliberately preserved so existing deployments keep
|
||||
// working; see ALLOWED_LEGACY_BRAND below for the reason each one survives.
|
||||
const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i]
|
||||
// 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',
|
||||
@@ -254,11 +323,25 @@ const SCAN_FILES = [
|
||||
]
|
||||
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__'])
|
||||
// This file necessarily spells out the patterns and allow-list entries it checks.
|
||||
const SCAN_SKIP_FILES = new Set(['scripts/harness-check.mjs'])
|
||||
// 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.
|
||||
// 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',
|
||||
@@ -269,7 +352,8 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/config.ts',
|
||||
reason: 'HERMES_WEB_UI_HOME / ~/.hermes-web-ui state directory is a preserved public contract',
|
||||
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',
|
||||
@@ -283,13 +367,39 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
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: 'packages/client/src/components/layout/SettingsCircuitBadge.vue',
|
||||
pattern: /const purchaseUrl = 'https:\/\/hermes-studio\.ai\/docs\//,
|
||||
reason: 'purchase badge links to the upstream docs site that hosts the ESP32 offer',
|
||||
},
|
||||
{
|
||||
file: 'tests/server/studio-mcp-autoinject.test.ts',
|
||||
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
|
||||
},
|
||||
{
|
||||
file: 'tests/client/markdown-rendering.test.ts',
|
||||
reason: 'fixture URL is arbitrary sample data, not project branding',
|
||||
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',
|
||||
@@ -299,10 +409,6 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
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/services/runtime-version-manager.ts',
|
||||
reason: 'release download host and repo slug are the runtime-version lookup contract; replaced in Phase 008',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -314,7 +420,7 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
]
|
||||
|
||||
// Legacy identifiers that stay valid everywhere, because they are public
|
||||
// deployment contracts rather than project branding.
|
||||
// deployment contracts or wire-protocol values rather than project branding.
|
||||
const GLOBAL_ALLOWED_PATTERNS = [
|
||||
/HERMES_WEB_UI[A-Z0-9_]*/,
|
||||
/HERMES_WEBUI_STATE_DIR/,
|
||||
@@ -322,6 +428,9 @@ const GLOBAL_ALLOWED_PATTERNS = [
|
||||
/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) {
|
||||
@@ -343,9 +452,10 @@ 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 => normalized === entry.file || normalized.startsWith(`${entry.file}/`),
|
||||
)
|
||||
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 = []
|
||||
@@ -367,7 +477,7 @@ for (const relativePath of scanFiles) {
|
||||
}
|
||||
}
|
||||
for (const hit of brandHits) {
|
||||
fail(`Legacy brand string must be removed (ekko / hermes-web-ui): ${hit}`)
|
||||
fail(`Legacy brand string must be removed (ekko / hermes-web-ui / hermes-studio): ${hit}`)
|
||||
}
|
||||
|
||||
// --- Retired runtime scan ----------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user