name: CI on: push: branches: [main, master] pull_request: # COMPAT-007 C1 探测轨(DEC-025 ADR 决策②②):宿主发版即红的定时探测——频率 SLO = 每日一次 # (RB-01:每日一次仍留 ≤1 天窗口期,窗口期内宿主升级可能先于探测到达用户;兜底与披露见 README「探测窗口期」)。 schedule: - cron: '17 3 * * *' # UTC 03:17(北京 11:17)——每日一次,不与整点高峰期抢注册表 workflow_dispatch: # 手动触发(发布前随查 / 怀疑宿主发版时随查) jobs: sanity: # COMPAT-014 A-F1(收口 REVIEW-COMPAT-007-R1 F1):`on.schedule` 是 workflow 级 ⇒ 若无事件门禁,既有 job # 亦进入每日无人值守执行面。本 job 反向 if:schedule 事件下 skipped(每日面收敛为 host-latest-probe), # pull_request / push / workflow_dispatch 照常执行(手动 dispatch = 全量随查语义,保持不变)。 if: github.event_name != 'schedule' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: 语法检查(宿主/工具/客户端三方 + 宿主契约 + 表面 fixtures 工具) run: | node --check lib/index.js node --check lib/tools.js node --check lib/client.js node --check lib/host-contract.mjs node --check test/fixtures/host-surfaces/extract.mjs node --check test/fixtures/host-surfaces/ci-mock-face.mjs node --check test/fixtures/host-surfaces/probe-face.mjs node --check scripts/probe-host.mjs - name: 依赖架构守卫(防双闭包陷阱) run: | node -e " const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); const deps = Object.keys(pkg.dependencies ?? {}); const bad = deps.filter((d) => d.startsWith('@deepseek-ai/')); if (bad.length > 0) throw new Error('@deepseek-ai/* must be peerDependencies, not dependencies (dual-closure trap): ' + bad.join(', ')); const peers = Object.keys(pkg.peerDependencies ?? {}); for (const need of ['@deepseek-ai/cordis', '@deepseek-ai/dsh-settings', '@deepseek-ai/dsh-tools']) { if (!peers.includes(need)) throw new Error('missing peerDependency: ' + need); } console.log('OK: dependency architecture (host closure via peers only)'); " - name: 预设挂载级静态校验(loader 同源 + 逐行解析) run: node test/validate-preset.mjs # COMPAT-003:宿主替身(mock)导出面 × 宿主表面 fixtures 对齐——mock 多导出宿主已删除的表面 # (如 settingsNamespace,BUG-003 根因)会让 CI 假绿而实机崩溃;本步骤把 mock 面钉在 fixtures 实测面上。 - name: CI mock 导出面 × 宿主表面 fixtures 对齐(COMPAT-003 F1 面钉) run: node test/fixtures/host-surfaces/ci-mock-face.mjs # COMPAT-014 A-F2(收口 REVIEW-COMPAT-007-R1 F2):探测轨判据的**离线**机检——原判据脚本(host-latest-probe # job 内联 heredoc)在 PR/push 面零机检(该 job 被 if 排除、既有 extractHeredocs 不匹配 `<<'PROBE_EOF'` 约定), # 语法/逻辑缺陷只能等首次 schedule 暴露。本步骤把提取 + 语法机检 + 构造 JSON 驱动判据真跑搬进 PR 门禁—— # 全程离线:无网络、无 install、不因宿主发版误红(检查的是**脚本**而非宿主版本,故不违背「只读网络探测不进 # PR 门禁」原意)。同步骤附事件门禁结构断言(A-F1)与命令白名单 / stem↔命令一一对应校验(A-F5)。 # COMPAT-015 增补:`on` **四键**完整性(F1)、接线**自断言**(F2:本步骤 MUST 留在 sanity 段内)、判据 ①/③/⑤ # 负例与 input×drift 同轮并报(F4/F5)、白名单自指面(F6)、timeout 与失败态归因(F7/F8)、以及 # **install 头部布局契约对账**(COMPAT-009 ⑧:两脚本 `host-contract:v1` 标记块 ↔ 契约 face 4/5 逐项正则对账)。 - name: 探测轨判据离线机检 + install 头部布局契约对账(提取 heredoc → node --check → 构造 JSON 驱动判据;白名单/门禁/契约对账断言) run: node test/fixtures/host-surfaces/probe-face.mjs # COMPAT-008:宿主能力探针 `scripts/probe-host.mjs` 的**离线自检**(零网络 / 零宿主进程):断言脚本可加载 + # 契约 × fixtures 能力面双向对账。**实机模式(--run:隔离实例起宿主 + 无头浏览器能力探测)需运行中的宿主环境, # 不在 CI 执行**(用法见 README「维护者:一条命令定位断在哪一层」节;该模式在本仓**未验证**,输出须人工确认)。 - name: 宿主能力探针离线自检(COMPAT-008;零网络 / 零宿主) run: node scripts/probe-host.mjs --self-check - name: 包结构检查 run: | node -e " const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); for (const f of ['cordis.patch.yml', 'lib/index.js', 'lib/tools.js', 'lib/client.js', 'install.ps1', 'install.sh', 'README.md']) { if (!fs.existsSync(f)) throw new Error('missing: ' + f); } if (!pkg.dsh?.bundle?.patch) throw new Error('dsh.bundle.patch missing'); if (!pkg.dsh?.client) throw new Error('dsh.client missing'); if (pkg.exports['./client'] !== './lib/client.js') throw new Error('client export missing'); const preset = 'agent-presets/novel-writing'; for (const f of ['agent.cordis.yml', 'preset.yml']) { if (!fs.existsSync(preset + '/' + f)) throw new Error('missing preset file: ' + f); } const skills = fs.readdirSync(preset + '/skills'); const indexed = skills.filter((s) => fs.existsSync(preset + '/skills/' + s + '/SKILL.md')); if (indexed.length < 25) throw new Error('too few skills indexed: ' + indexed.length); console.log('OK: structures valid, skills=' + indexed.length); " host-logic: # COMPAT-014 A-F1:反向 if——schedule 下本 job skipped(其中含一次**未钉版本**的真实包安装 + 全量 smoke, # 不应进每日无人值守面);push / pull_request / workflow_dispatch 照常执行,真实 schemastery 漂移检测不变。 if: github.event_name != 'schedule' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 # 宿主逻辑冒烟需要 @deepseek-ai 闭包的最小子集。schemastery 是无下游依赖的 # 独立库,直接从 registry 装真包(mock 无法覆盖 .default()/.union 等链式 API); # 其余四个用行为兼容的最小替身。 - name: 宿主服务逻辑冒烟(schemastery 真包 + 其余最小替身) run: | mkdir -p node_modules/@deepseek-ai/cordis node_modules/@deepseek-ai/dsh-settings node_modules/@deepseek-ai/dsh-home-paths node_modules/@deepseek-ai/dsh-tools npm install --no-save @deepseek-ai/schemastery >/dev/null 2>&1 cat > node_modules/@deepseek-ai/cordis/index.js <<'EOF' export class Service { constructor(ctx, name) { ctx.reflect.provide(name, this) } } EOF printf '{ "name": "@deepseek-ai/cordis", "version": "0.0.0-mock", "type": "module", "main": "index.js" }\n' > node_modules/@deepseek-ai/cordis/package.json # dsh-settings mock:导出面逐项对齐宿主真实面,防已删除表面在 CI「复活」(BUG-003 温床,COMPAT-010)。 # 权威源(宿主只读闭包,禁止写入):node_modules/@deepseek-ai/dsh-settings/lib/index.js L610—— # export { SettingsConflictError, SettingsProvider, SettingsProvider as default, redactSecrets }; # 形态(同文件):SettingsConflictError=Error 子类 name/code/expected/actual(L92);SettingsProvider=cordis # Service 子类(L223)且 default 为其别名;redactSecrets(schema,value)→{value,secrets}(L66)。无 settingsNamespace。 cat > node_modules/@deepseek-ai/dsh-settings/index.js <<'EOF' import { Service } from '@deepseek-ai/cordis' export class SettingsConflictError extends Error { constructor(ns, expected, actual) { super(`settings namespace "${ns}" changed since it was read (expected revision ${String(expected)}, now ${String(actual)})`) this.name = 'SettingsConflictError' this.code = 'SETTINGS_CONFLICT' this.expected = expected this.actual = actual } } export class SettingsProvider extends Service {} export default SettingsProvider export function redactSecrets(schema, value) { return { value, secrets: [] } } EOF printf '{ "name": "@deepseek-ai/dsh-settings", "version": "0.0.0-mock", "type": "module", "main": "index.js" }\n' > node_modules/@deepseek-ai/dsh-settings/package.json cat > node_modules/@deepseek-ai/dsh-home-paths/index.js <<'EOF' import { homedir } from 'node:os' import { join } from 'node:path' export const resolveDshHome = () => join(homedir(), '.dsh') EOF printf '{ "name": "@deepseek-ai/dsh-home-paths", "version": "0.0.0-mock", "type": "module", "main": "index.js" }\n' > node_modules/@deepseek-ai/dsh-home-paths/package.json cat > node_modules/@deepseek-ai/dsh-tools/index.js <<'EOF' export const defineTool = (def) => def EOF printf '{ "name": "@deepseek-ai/dsh-tools", "version": "0.0.0-mock", "type": "module", "main": "index.js" }\n' > node_modules/@deepseek-ai/dsh-tools/package.json node -e "import('@deepseek-ai/schemastery').then((m) => console.log('schemastery real package:', typeof m.default.object))" node test/smoke.mjs # ── COMPAT-007:宿主发版探测轨(C1;DEC-025 ADR 决策②②)──────────────────────────────── # 判据:上游已发布版本 × fixtures 覆盖版本对账——检测到新宿主版本 ⇒ 本 job 红(在用户升级前暴露适配面)。 # 频率 SLO:每日一次(见上方 on.schedule cron);窗口期残余(RB-01)与兜底见 README「探测窗口期」。 # 触发面:仅 schedule / workflow_dispatch——只读网络探测不进 PR 门禁(宿主 rc 版直接打 latest tag,契约 6.5)。 host-latest-probe: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: read env: PROBE_DIR: ${{ runner.temp }}/nv-host-probe steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 # ── 供应链白名单(硬约束;BC-05:探测轨自身不得成为供应链风险源)───────────────────── # 允许:仅 `npm view --json`——只读注册表元数据,无包落盘、无代码执行。 # 禁止(本 job 出现即违规):npm install / npm ci / npx / npm pack / 解包 tarball 后 import 或执行、 # 任何形式的宿主代码运行、actions/cache 复用(防把宿主包带进工作区)。 # 理由:C1 的价值是「宿主发版即红」;若为此 install rc 版宿主闭包(200+ 包、rc 直接打 latest tag # ——契约 6.5 / BC-05),探测轨本身即引入供应链面与「探测轨成为事故源」风险。版本元数据足够支撑判据。 # 新增白名单外命令 MUST 经独立审查(同契约「新增宿主耦合字面量必须同步契约」纪律)。 - name: 只读探测 npm 元数据(白名单:仅 npm view) run: | mkdir -p "$PROBE_DIR" npm view @deepseek-ai/dsh versions --json > "$PROBE_DIR/dsh.versions.json" npm view @deepseek-ai/dsh dist-tags --json > "$PROBE_DIR/dsh.dist-tags.json" npm view @deepseek-ai/dsh-settings versions --json > "$PROBE_DIR/dsh-settings.versions.json" npm view @deepseek-ai/dsh-api-gateway versions --json > "$PROBE_DIR/dsh-api-gateway.versions.json" npm view @deepseek-ai/dsh-client-modules versions --json > "$PROBE_DIR/dsh-client-modules.versions.json" npm view @deepseek-ai/dsh-client-connection versions --json > "$PROBE_DIR/dsh-client-connection.versions.json" npm view @deepseek-ai/dsh-tools versions --json > "$PROBE_DIR/dsh-tools.versions.json" npm view @deepseek-ai/dsh-home-paths versions --json > "$PROBE_DIR/dsh-home-paths.versions.json" npm view @deepseek-ai/cordis versions --json > "$PROBE_DIR/cordis.versions.json" npm view @deepseek-ai/schemastery versions --json > "$PROBE_DIR/schemastery.versions.json" echo "OK: 只读元数据探测完成(无 install / 无宿主代码执行)" - name: 契约 × fixtures × registry 版本对账(检测到新宿主版本 ⇒ job 红) run: | cat > "$RUNNER_TEMP/nv-host-latest-probe.mjs" <<'PROBE_EOF' // host-latest-probe.mjs — COMPAT-007 C1「latest 探测轨」判据(DEC-025 ADR 决策②②)。 // 只读:本脚本不联网、不发命令、不写仓库——只消费(i)上一「只读探测」步骤落盘的 npm view JSON、 // (ii)test/fixtures/host-surfaces/*.json(覆盖版本唯一机读事实源)、(iii)lib/host-contract.mjs 声明面(纯数据)。 // 退出码:0 = 无新版本(绿);1 = 检测到新版本 / 覆盖失配 / 口径外形态(红);2 = 用法或输入缺失 / // 探测输入不可解析(红;COMPAT-014 A-F7——`npm view` 非零退出只留空文件,原实现会 JSON.parse 抛错 // + 栈回溯并以 1 退出,与本节声明不符,且单包损坏会吞掉其余包的结论)。 // // 判据(逐包可归因): // ① 探测面 ≡ 契约 hostSurface.packages 并集(双向:漏探=红、越界探=红)——契约是探测目标集单一事实源; // ①b 版本维双向对账(COMPAT-014 A-F4):契约 packages 键集 ≡ fixtures hostVersion 集 ∧ current ∈ 覆盖集 // ——① 只对包名维对账,版本维可「只增 fixture / 只改契约」静默解耦(「重建 fixtures 关红」的缝隙); // ② fixtures 覆盖版本 MUST 仍在上游已发布列表内(否则覆盖声明陈旧或被撤回)——**CLI 不适用** // (COMPAT-014 A-F3):CLI 的 covered 是 hostVersion **代理**,与 CLI 发版号不保证一致,套用本子句 // 会因非宿主原因误红并误导归因;CLI 检测力由 ④ 版本车判据承担(报文含代理限定语); // ③ fixtures 自洽:host 族包版本 MUST === hostVersion;versionExceptions 包 MUST ≠ hostVersion(防例外表腐化); // ④ 版本车判据:已覆盖车(major.minor.patch 三元组)内的上游最大版本 MUST ≡ 已覆盖版本,且上游 MUST NOT // 出现高于最新已覆盖车的车 ⇒ 违反即输出「detected new host version X, fixtures covered Y ⇒ 需审阅+更新 fixtures」。 // CLI(@deepseek-ai/dsh)单列:fixtures 只记闭包子包、无 CLI 版本机读位 ⇒ 覆盖面以 hostVersion 集合为代理 // (fixture 头自述「CLI 版本闭包内子包实测版本 = hostVersion」;代理只会更早判红,不掩盖更新版本)。 // // 为何不复用各包 `npm view version`(latest dist-tag)直接比对——本任务实测订正(P-01): // dsh-* 子包 latest tag 停在 0.0.1-rc.X 旧线(实测 dsh-settings latest=0.0.1-rc.1、dsh-home-paths=0.0.1-rc.3), // 宿主线实际发布在 next/alpha tag(0.1.5-rc.2)⇒ 用 latest 比对会对**已覆盖版本**恒判红(假阳)。 // 故判据取「已发布版本列表 + 版本车比较」,与 dist-tag 命名解耦;dist-tags 仅作上下文报告(非判据)。 // 受限版本口径:X.Y.Z 或 X.Y.Z-{alpha|rc}.N(本宿主线实测命名)。未知形态无法排序 ⇒ fail-closed 判红 // (不静默跳过:无法判定的版本 = 尚未审阅的版本)。 import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { pathToFileURL } from 'node:url' const [probeDir, fixturesDir, contractPath] = process.argv.slice(2) const die = (msg) => { console.error('[COMPAT-007 探测轨] 输入错误:' + msg); process.exit(2) } // 失败分类处置(COMPAT-014 A-F6,收口 REVIEW-COMPAT-007-R1 F6):三类红的关闭路径不同——口径类红 // **无法**靠「更新 fixtures」关闭(MUST 扩受限口径/人工审阅该形态);面类红需同步探测行或契约声明面; // 覆盖/自洽类与版本推进类才走 fixtures 重建。原文案对所有类别统一说「重建 fixtures」⇒ 运维者会走错路径。 const DISPOSAL = { 'input': '检查 npm view 是否成功落盘(registry 抖动 / 网络 / 包下架)后重跑;空文件 = npm view 非零退出的残留,**不是**宿主发版', 'surface': '同步探测行(白名单内 `npm view @deepseek-ai/ versions --json`)与契约 hostSurface 声明面(packages / current / versionExceptions)——两者 MUST 双向一致', 'coverage': '审阅覆盖声明 → 以 test/fixtures/host-surfaces/extract.mjs **只读重建** fixtures(契约与 fixtures 须同步改,只改一侧即本类红)', 'version-form': '版本形态超出受限口径(X.Y.Z 或 X.Y.Z-{alpha|rc}.N):**扩口径/人工审阅该形态**(parseVersion 与 TRACK 表)——重建 fixtures 关不掉本类红', 'version-drift': '宿主发版类:审阅新版本变更面 → 以 extract.mjs 只读重建 fixtures → 复核契约声明面 → 适配后关红', } if (!probeDir || !fixturesDir || !contractPath) die('用法:node host-latest-probe.mjs ') if (!existsSync(probeDir)) die('probe 目录不存在:' + probeDir) if (!existsSync(fixturesDir)) die('fixtures 目录不存在:' + fixturesDir) const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')) const { hostContract } = await import(pathToFileURL(contractPath).href) const surface = hostContract.hostSurface const declared = [...new Set(Object.values(surface.packages).flat())].sort() const exceptions = new Set(surface.versionExceptions) // ── 受限版本口径(train = major.minor.patch 三元组;track:alpha < rc < stable)── const TRACK = { alpha: 0, rc: 1, stable: 2 } const parseVersion = (v) => { if (typeof v !== 'string') return null const m = /^(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?$/.exec(v) if (!m) return null const track = m[4] === undefined ? TRACK.stable : TRACK[m[4]] if (track === undefined) return null return { v, train: [Number(m[1]), Number(m[2]), Number(m[3])], track, pre: m[5] === undefined ? -1 : Number(m[5]) } } const cmpTrain = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]) const cmpVersion = (a, b) => cmpTrain(a.train, b.train) || (a.track - b.track) || (a.pre - b.pre) const sameTrain = (a, b) => cmpTrain(a.train, b.train) === 0 const trainKey = (x) => x.train.join('.') // ── fixtures 覆盖面(唯一机读覆盖事实源:每份 hostVersion + 各包 version)── const fixtures = readdirSync(fixturesDir).filter((f) => f.endsWith('.json')).sort() .map((f) => ({ file: f, ...readJson(join(fixturesDir, f)) })) if (fixtures.length === 0) die('fixtures 目录无 *.json:' + fixturesDir) const coveredHost = new Set() const coveredByPkg = new Map() for (const fx of fixtures) { coveredHost.add(fx.hostVersion) for (const [p, meta] of Object.entries(fx.packages ?? {})) { if (!coveredByPkg.has(p)) coveredByPkg.set(p, new Set()) coveredByPkg.get(p).add(meta.version) } } const failures = new Map() // msg → 类别(A-F6 分类处置) const fail = (cls, msg) => failures.set(msg, cls) const inputProblems = [] // A-F7:探测输入缺失/损坏(逐包归因;退出码 2) const okLines = [] const targets = [...declared, 'dsh'] // 'dsh' = @deepseek-ai/dsh(CLI 单列探测面) const probedStems = readdirSync(probeDir).filter((f) => f.endsWith('.versions.json')).map((f) => f.replace('.versions.json', '')) // ① 探测面 ≡ 契约声明面(双向 ⊆) for (const p of declared) if (!probedStems.includes(p)) fail('surface', `探测面缺契约声明包 ${p}:契约 hostSurface.packages 已声明 ⇒ MUST 同步补 npm view 探测行(白名单内)`) for (const s of probedStems) if (!targets.includes(s)) fail('surface', `探测面含契约未声明的包 ${s}:探测目标集单一事实源是契约 hostSurface.packages(越界探测)`) // ①b 版本维双向对账(COMPAT-014 A-F4):契约 packages 键集 ≡ fixtures hostVersion 集 ∧ current ∈ 覆盖集 const contractVersions = Object.keys(surface.packages).sort() for (const v of contractVersions) if (!coveredHost.has(v)) fail('surface', `契约 hostSurface.packages 声明版本 ${v} 在 fixtures 中无快照 ⇒ 契约声明版本面与 fixtures 失配(版本维双向对账,A-F4)`) for (const v of [...coveredHost].sort()) if (!contractVersions.includes(v)) fail('surface', `fixtures 快照版本 ${v} 不在契约 hostSurface.packages 键集 ⇒ 契约声明面陈旧(版本维双向对账,A-F4)`) if (!coveredHost.has(surface.current)) fail('surface', `契约 hostSurface.current=${surface.current} 不在 fixtures 覆盖集内(版本维对账,A-F4)`) for (const p of targets) { const probeFile = join(probeDir, p + '.versions.json') if (!existsSync(probeFile)) { inputProblems.push(`${p}:探测输入缺失 ${p}.versions.json(npm view @deepseek-ai/${p} versions --json 未落盘)`); continue } const before = failures.size const isCli = p === 'dsh' let raw = null try { const text = readFileSync(probeFile, 'utf8') if (text.trim() === '') throw new Error('空文件(npm view 非零退出只留空文件)') raw = JSON.parse(text) } catch (e) { inputProblems.push(`${p}:探测输入不可解析(${p}.versions.json)——${e instanceof Error ? e.message : String(e)}`) continue } const list = Array.isArray(raw) ? raw : [raw] if (list.some((v) => typeof v !== 'string')) { inputProblems.push(`${p}:探测输入形态异常(非版本字符串数组)`); continue } const covered = [...(isCli ? coveredHost : (coveredByPkg.get(p) ?? []))] const cov = covered.map(parseVersion) const badCov = covered.filter((_, i) => !cov[i]) if (badCov.length > 0) { fail('version-form', `覆盖版本形态超出受限口径:${p} ${badCov.join(', ')} ⇒ 无法判定(fail-closed,需人工审阅)`); continue } if (cov.length === 0) { fail('coverage', `契约声明面 ${p} 在 fixtures 中无覆盖版本 ⇒ 声明面与 fixtures 失配(需审阅 + 更新 fixtures)`); continue } cov.sort(cmpVersion) const newestCov = cov[cov.length - 1] const pub = list.map(parseVersion) const badPub = list.filter((_, i) => !pub[i]) if (badPub.length > 0) fail('version-form', `上游已发布版本形态超出受限口径:${p} ${badPub.join(', ')} ⇒ 无法判定新旧(fail-closed,需人工审阅)`) const pubOk = pub.filter(Boolean) const upstreamNewest = pubOk.slice().sort(cmpVersion).pop() // ② 覆盖版本仍在上游已发布列表内(CLI 不适用——覆盖串为 hostVersion 代理,见文件头 ② 说明,A-F3) if (!isCli) for (const c of cov) if (!list.includes(c.v)) fail('coverage', `fixtures 覆盖版本 ${c.v} 不在上游已发布列表(${p})⇒ 覆盖声明陈旧或被撤回,需审阅`) // ③ fixtures 自洽(host 族 ≡ hostVersion;例外表 MUST ≠ hostVersion) if (!isCli) { for (const fx of fixtures) { const meta = fx.packages?.[p] if (!meta) continue if (!exceptions.has(p) && meta.version !== fx.hostVersion) fail('coverage', `fixtures 自洽:${fx.file} 中 ${p}=${meta.version} ≠ hostVersion=${fx.hostVersion}(host 族包应与宿主同版本)`) if (exceptions.has(p) && meta.version === fx.hostVersion) fail('coverage', `版本例外表腐化:${fx.file} 中 ${p}=${meta.version} ≡ hostVersion(例外包属独立版本族,MUST ≠ 宿主版本)`) } } // ④ 版本车判据(同车:上游最大 vs 覆盖最大;另有高于最新覆盖车的车 ⇒ 新车) const found = new Map() for (const k of [...new Set(cov.map(trainKey))]) { const maxCov = cov.filter((c) => trainKey(c) === k).sort(cmpVersion).pop() const maxPub = pubOk.filter((x) => sameTrain(x, maxCov)).sort(cmpVersion).pop() if (maxPub && cmpVersion(maxPub, maxCov) > 0) found.set(maxPub.v + '|' + maxCov.v, [maxPub.v, maxCov.v]) } for (const x of pubOk) if (cmpTrain(x.train, newestCov.train) > 0) found.set(x.v + '|' + newestCov.v, [x.v, newestCov.v]) for (const [newV, covV] of found.values()) fail('version-drift', `detected new host version ${newV}, fixtures covered ${covV} ⇒ 需审阅 + 更新 fixtures(${p})`) if (failures.size === before) okLines.push(`${p}:上游最新 ${upstreamNewest ? upstreamNewest.v : 'n/a'} ≡ fixtures 覆盖 ${newestCov.v}(覆盖车 ${[...new Set(cov.map(trainKey))].join(' / ')}${exceptions.has(p) ? ';独立版本族' : ''}${isCli ? ';CLI 覆盖面 = hostVersion 代理(子句②不适用)' : ''})`) } // ── 报告 ── console.log('[COMPAT-007 探测轨] 只读判据:契约声明面 × fixtures 覆盖 × registry 已发布版本') console.log(` 契约声明面包(${declared.length}):${declared.join(', ')}(版本例外:${[...exceptions].join(', ')})`) console.log(` fixtures 覆盖面(${fixtures.length}):${fixtures.map((f) => String(f.hostVersion)).join(' / ')}`) for (const l of okLines) console.log(' ✅ ' + l) const distTagsFile = join(probeDir, 'dsh.dist-tags.json') if (existsSync(distTagsFile)) { try { console.log(' ℹ️ @deepseek-ai/dsh dist-tags(上下文,非判据):' + JSON.stringify(readJson(distTagsFile))) } catch { console.log(' ℹ️ @deepseek-ai/dsh dist-tags 文件不可解析(上下文,非判据,不影响结论)') } } // 失败分类打印(单点)——两条退出路径(input 优先 / 常规)共用,避免两处各写一份后漂移。 const groupFailures = () => { const byClass = new Map() for (const [msg, cls] of failures) { if (!byClass.has(cls)) byClass.set(cls, []) byClass.get(cls).push(msg) } return byClass } const printFailureClasses = () => { for (const [cls, msgs] of groupFailures()) { console.error(` ── 类别 ${cls}(${msgs.length} 项)`) for (const m of msgs) console.error(' - ' + m) console.error(' ⇒ 处置:' + DISPOSAL[cls]) } } // 输入异常优先(A-F7):退出码 2 = 用法/输入缺失,与「检测到新版本」区分,逐包归因。 // COMPAT-015 F5(收口 REVIEW-COMPAT-014-R1 F5):原实现本分支直接 process.exit(2) 且位于 failures 报告**之前** // ⇒ 同轮「一包输入异常 + 其它包检出宿主新版本」时,发版检出被**完全吞掉**(报文只有 input),宿主发版暴露延后 // 一轮。现本分支在退出前**并报**已累计的 failures(分类摘要 + 明示退出码仍取 2 = 输入类优先):input 类不掩盖 // drift 类,退出码语义(A-F7 归因正确性)保持不变。 if (inputProblems.length > 0) { console.error(`❌ [COMPAT-007 探测轨] 探测输入异常 ${inputProblems.length} 项(退出码 2 = 用法/输入缺失,**非**宿主发版):`) for (const f of inputProblems) console.error(' - ' + f) console.error('⇒ 处置(input):' + DISPOSAL.input) if (failures.size > 0) { console.error(`⚠️ 同轮另检出 ${failures.size} 项判据失败(**未被输入异常掩盖**;退出码仍取 2 = 输入类优先——修复输入后重跑,同一结论会以退出码 1 复现):`) printFailureClasses() } process.exit(2) } if (failures.size === 0) { console.log('✅ 无新宿主版本:上游已发布版本均未超出 fixtures 覆盖(探测频率每日一次;窗口期残余见 README「探测窗口期」)') process.exit(0) } const byClass = groupFailures() console.error(`❌ [COMPAT-007 探测轨] 检出 ${failures.size} 项(按类别处置,退出码 1;${byClass.size} 类):`) printFailureClasses() process.exit(1) PROBE_EOF node "$RUNNER_TEMP/nv-host-latest-probe.mjs" "$PROBE_DIR" test/fixtures/host-surfaces lib/host-contract.mjs