From 9d16d53eab6bc48238e582f6bc24d73601098a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:53:17 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20check:liveness=20=E7=9A=84=20s?= =?UTF-8?q?tale-evidence=20=E5=88=A4=E7=BA=A2,=E6=91=98=E8=A6=81=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E4=B8=8E=E8=AF=8D=E5=AF=B9=E9=BD=90=20(#5623?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `live` 判定的语义就是它的 evidence 指针。指针指向本仓已不存在的文件时, 这条声明不再可证伪 —— declared 有,enforced 无 —— 而一次目录重组或改名 就足以造成它。实测(origin/main,故意改坏 query.json 的 5 条路径):逐条 点名了,退出码仍是 0;摘要行的 330 也一动没动,因为它数的是 local 路径 总数,不是解析成功数。 - `live` 条目引用本仓缺失文件 → `✗` 判红(退出码 1),并给出三条修法。 边界不变:cross-repo attribution(objectui / cloud / service-ai,当前 101 条)只计数不解析,永远不判红 —— checkEvidence 只对 local 桶做存在 性检查,这是结构性的分界。 - 摘要行改成两个数:declared 与 resolved,绿的时候相等;有断链时追加 `, N MISSING`。保留 declared 是因为它是 #3857 留下的解析健康度信号。 - 新增 `--ledger-root=`,自测据此把真 gate 跑在只坏了一条指针的 ledger 副本上,不改动仓库任何文件。 以前是 ⚠ 不是对本仓路径的有意宽容,是解析器时代的遗留:#3857 之前 `evidence.split(':')[0]` 在 227 条里报 48 条、全是误报。同族的 check-empty-state 对 rotted-evidence 一直是 ✗ + exit 1。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .../liveness-stale-evidence-fails-ci.md | 37 ++++ packages/spec/liveness/README.md | 13 +- .../spec/scripts/liveness/check-liveness.mts | 85 +++++++- .../scripts/liveness/check-liveness.test.ts | 183 ++++++++++++++++++ 4 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 .changeset/liveness-stale-evidence-fails-ci.md create mode 100644 packages/spec/scripts/liveness/check-liveness.test.ts diff --git a/.changeset/liveness-stale-evidence-fails-ci.md b/.changeset/liveness-stale-evidence-fails-ci.md new file mode 100644 index 0000000000..f818c3762d --- /dev/null +++ b/.changeset/liveness-stale-evidence-fails-ci.md @@ -0,0 +1,37 @@ +--- +--- + +chore(spec): `check:liveness` 的 stale-evidence 从 `⚠` 升级为 `✗` 判红,摘要行不再把「声明数」当作「解析成功数」(#5623) + +`live` 判定的语义就是它的 evidence 指针 ——「这个属性有运行时消费者,在这里」。指针指向本仓已不存在的文件时,这条声明就不再可证伪:declared 有,enforced 无。而把它变成这样,只需要一次目录重组或一次改名。 + +实测(`origin/main`,把 `packages/spec/liveness/query.json` 的 5 条 evidence 路径指回迁移前的 `packages/plugins/driver-sql/...`): + +``` +evidence paths: 330 resolved against this checkout, 101 attributed to another repo (…) + +⚠ 5 'live' entr(ies) cite a missing file: + query/fields → packages/plugins/driver-sql/src/sql-driver.ts + … +``` + +退出码 **0**。五条断链逐条点名了,CI 一路绿灯 —— 而且摘要行里的 `330` 一动没动,因为它数的是 local 路径**总数**,不是解析成功数,"resolved" 这个词是失真的。 + +**为什么以前是 `⚠`,以及为什么现在不该是。** 这不是对本仓路径的有意宽容,是解析器时代的遗留:#3857 之前的检查取 `evidence.split(':')[0]` 当文件名,227 条里报 48 条、**全是误报**,那种情况下它当然不能判红。`evidence.mts` 修好了解析(realm marker 归属 + 仓根路径提取),此后这份清单一直是空的 —— 恰恰是这一点让「有命中」重新变成信号。当时被那 48 条噪声埋掉的唯一一条真腐烂(`object.enable.clone`,消费者从 `@objectstack/objectql` 搬到了 `@objectstack/metadata-protocol`),就是宽容的实际代价。 + +同族的旁证:同一目录下的 `check-empty-state.mts` 对 `rotted-evidence` 一直是 `✗` + exit 1,README 也写着它的执行点路径「resolves like `evidence` above, so a pointer that rots is reported rather than trusted」。真正有意的宽容在这个 gate 里都写明了理由(`verifiedAt` 的年龄、undrilled 容器计数、`PENDING_GOVERNANCE` —— 每一条都在代码里明说 "never fails CI"),stale-evidence 那一段一个字都没有。 + +**改动** + +1. **`live` 条目引用本仓缺失文件 → `✗` 判红**,并给出三条修法(仓内搬家就改指针并补 `verifiedAt`;搬去别的仓就加 realm marker;消费者真没了就按 ADR-0049 重新判定,而不是随手指向一个看着像的幸存者)。 + **边界不变**:cross-repo attribution(`objectui:` / `cloud:` / `packages/services/service-ai/…`,当前 101 条)只计数、不解析,永远不判红 —— `checkEvidence` 只对 local 桶做存在性检查,这个分界是结构性的,不是靠约定。 +2. **摘要行改成两个数**,而不是二选一: + ``` + evidence paths: 330 repo-local path(s) declared by 'live' entries, 330 resolved against this checkout; 101 attributed to another repo (…) + ``` + 两个数都留着是有原因的:"declared" 是 #3857 留下的解析健康度信号(有单测防止解析器退化成「什么都提不出来」而假绿),"resolved" 是判定。绿的时候两者相等 —— 这正是当初只印前一个会被读成通过的原因。有断链时行尾追加 `, N MISSING`。 +3. 新增 `--ledger-root=`:让 gate 读 `packages/spec/liveness` 的一份副本。自测据此把真 gate 跑在「只坏了一条指针」的 ledger 副本上,不改动仓库里任何文件。 + +`check-liveness.test.ts`(新增 8 例)直接 spawn 真脚本断言退出码 —— 这个 bug 从来不是「检查看不见」:它把五条全点名了还是 exit 0,所以只测 `checkEvidence` 的单测全程是绿的。 + +纯开发脚本 / CI gate 改动,不改任何运行时或可发布的产物,因此用空 frontmatter changeset(不发布任何包)。当前 `main` 上 330 条本仓路径全部解析成功,该 gate 落地即绿。 diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index a34b56f0aa..df1129ff97 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -74,7 +74,18 @@ checkout. Prose around the paths is fine and encouraged — `packages/spec/src/s (mergeActionsIntoObjects stable-sorts each group)` resolves the path and ignores the parenthetical. -**A path in another repo must say so**, or the gate will report it as rot: +**A repo-local path that does not resolve FAILS CI** (`✗`, exit 1 — since #5623; +it was a non-failing `⚠` before, calibrated for the parser bug described in the +note below rather than for the parser we have). A `live` verdict *is* its +evidence pointer, so a pointer into thin air is a claim nothing can falsify — and +a directory move or a rename is the whole cost of getting there. Repair it, or, +if the consumer is genuinely gone, re-classify the property under ADR-0049 +enforce-or-remove instead of repointing at a plausible survivor. + +**A path in another repo must say so**, or the gate will report it as rot — +attributed paths are counted and never resolved, so they cannot fail this check +(that boundary is deliberate: ~101 entries cite the renderer repo or the closed +cloud runtime, whose files are legitimately absent here): ```jsonc "evidence": "objectui: packages/app-shell/src/views/RecordDetailView.tsx:573" diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 2a0c8c33a1..079f4396ba 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -41,6 +41,17 @@ // (see proof-registry.mts), a `live` classification MUST carry a valid proof — // the file must exist and declare the `@proof: ` tag. CI fails otherwise. // +// EVIDENCE POINTERS (ADR-0087): a `live` verdict IS its evidence pointer — "this +// property has a runtime consumer, here it is". A cited path that is repo-rooted +// and attributed to THIS repo must resolve against this checkout, or CI fails: +// an unresolvable pointer makes the claim unfalsifiable, and a directory move or +// a rename is all it takes (see evidence.mts). Cross-repo attribution +// (`objectui: …`, `cloud: …`, `packages/services/service-ai/…`) is counted, not +// resolved — those files are legitimately absent here. This was a ⚠ until #5623, +// for one historical reason: the pre-#3857 `evidence.split(':')[0]` parser +// flagged 48 of 227 entries with a ~100% false-positive rate, so failing on it +// would have failed every build. The parse fix is what turned a hit into signal. +// // RE-VERIFICATION CLOCK (`verifiedAt`): a ledger entry is a claim with a // timestamp, and code moves under it in BOTH directions — `flow.status` (#3711) // and `action.undoable` (#3714) were both understated by entries that were @@ -56,6 +67,10 @@ // tsx check-liveness.mts --stale-verification # print the re-verification worklist // tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold // tsx check-liveness.mts --undrilled # print the undrilled-container worklist +// tsx check-liveness.mts --ledger-root= # read the ledgers from instead of +// # packages/spec/liveness — how the self-test +// # runs the REAL gate against a mutated copy +// # of the real ledgers without touching them process.env.OS_EAGER_SCHEMAS = '1'; @@ -93,7 +108,22 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); // packages/spec const repoRoot = resolve(specRoot, '../..'); -const ledgerRoot = join(specRoot, 'liveness'); + +const args = process.argv.slice(2); + +// `--ledger-root=` points the walk at a COPY of packages/spec/liveness. +// It exists because the evidence gate below now fails the build, and a gate that +// fails is only worth as much as the proof that it fails — which needs a rotted +// pointer to fail ON. Committing one to a shipped ledger is not an option (it +// would fail every other run), and mutating a tracked file mid-test leaves the +// worktree dirty when the test crashes. So the self-test copies the real ledgers +// to a temp dir, breaks exactly one pointer there, and runs THIS script — same +// code path CI runs, no repo state touched. Everything else still resolves +// against the real repoRoot, so the resulting exit 1 has exactly one cause. +const ledgerRootArg = args.find((a) => a.startsWith('--ledger-root=')); +const ledgerRoot = ledgerRootArg + ? resolve(ledgerRootArg.slice('--ledger-root='.length)) + : join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) @@ -262,7 +292,6 @@ function loadLedger(type: string): any { } // ---- dump mode ---- -const args = process.argv.slice(2); const dumpIdx = args.indexOf('--dump'); if (dumpIdx !== -1) { const type = args[dumpIdx + 1]; @@ -300,8 +329,15 @@ const report: any = { deferredContainers: [] as string[], // containers whose subtree IS classified elsewhere — resolved, not believed deferredChildKeys: 0, // how many child keys those resolved deferrals actually cover verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist - evidenceLocal: 0, // repo-rooted evidence paths actually resolved against this checkout - evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here + // The three evidence counters, and the distinction between the first two is + // the whole point: `evidenceLocal` is how many repo-rooted paths `live` entries + // DECLARE, `evidenceMissing` is how many of those do not exist here. The + // summary line used to print `evidenceLocal` under the word "resolved", so + // breaking five pointers left the count at 330 and the run still said + // "330 resolved" (#5623). Count and word now agree. + evidenceLocal: 0, // repo-rooted evidence paths attributed to THIS repo — DECLARED, not yet proven + evidenceMissing: 0, // ...of which this many do not exist here (=== staleEvidence.length) — FAILS the gate + evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here, never failed }; // Every classified entry, for the `verifiedAt` fold below. Collected during the @@ -324,6 +360,7 @@ function classify(type: string, path: string, status: string, led: any, cat: any const ev = checkEvidence(led.evidence, (p) => existsSync(join(repoRoot, p))); report.evidenceForeign += ev.foreign.length; report.evidenceLocal += ev.local.length; + report.evidenceMissing += ev.missing.length; for (const miss of ev.missing) report.staleEvidence.push(`${type}/${path} → ${miss}`); } // ── ADR-0054 prove-it-runs ── @@ -507,6 +544,11 @@ const totalProofFailures = report.proofErrors.length + report.proofMissing.lengt const failed = totalUnclassified > 0 || totalProofFailures > 0 || + // A `live` entry whose repo-local evidence path is gone. Red since #5623 — the + // ⚠ it replaces was calibrated for the false-positive era, not for the parser + // that now resolves 330 paths and reports zero. Cross-repo attribution never + // reaches this list: checkEvidence only resolves the LOCAL bucket. + report.staleEvidence.length > 0 || report.orphanEntries.length > 0 || report.verification.errors.length > 0 || report.ungoverned.length > 0 || @@ -524,13 +566,39 @@ if (asJson) { } const boundClasses = HIGH_RISK_CLASSES.filter((c) => c.bound).map((c) => c.label); console.log(`\nprove-it-runs (ADR-0054): proof REQUIRED for bound high-risk classes — ${boundClasses.join(', ') || 'none'}`); + // Two numbers, because one cannot carry both facts. "Declared" is the + // extraction-health signal (#3857's unit test guards it against degrading to + // "extracts nothing"); "resolved" is the verdict. They are equal on a green + // run, which is exactly why printing only the first read as a pass. console.log( - `\nevidence paths: ${report.evidenceLocal} resolved against this checkout, ` + - `${report.evidenceForeign} attributed to another repo (objectui / cloud — not resolvable here).`, + `\nevidence paths: ${report.evidenceLocal} repo-local path(s) declared by 'live' entries, ` + + `${report.evidenceLocal - report.evidenceMissing} resolved against this checkout` + + (report.evidenceMissing ? `, ${report.evidenceMissing} MISSING` : '') + + `; ${report.evidenceForeign} attributed to another repo (objectui / cloud — not resolvable here).`, ); if (report.staleEvidence.length) { - console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file:`); + console.log(`\n✗ ${report.staleEvidence.length} 'live' entr(ies) cite a file that is missing from THIS repo:`); report.staleEvidence.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n A `live` verdict IS its evidence pointer — "this property has a runtime consumer,\n' + + ' here it is". When the cited file is gone from this checkout the claim is no longer\n' + + ' falsifiable: declared, but nothing enforces that anything still reads the property,\n' + + ' and a directory move or a rename is the whole cost of getting there.\n\n' + + ' Three repairs, and picking the right one is the work:\n' + + ' • the consumer MOVED inside this repo → repoint the path, and stamp `verifiedAt`\n' + + ' while you have the call graph open;\n' + + ' • the consumer moved to ANOTHER repo → say so with a realm marker\n' + + ' (`objectui: packages/app-shell/…`, `cloud: …`). Attributed paths are counted,\n' + + ' never resolved, and never fail here — that boundary is deliberate;\n' + + ' • the consumer is GONE → the verdict is not `live` any more. Re-classify under\n' + + ' ADR-0049 enforce-or-remove instead of repointing at a plausible survivor.\n\n' + + ' This was a ⚠ until #5623 for one reason: the pre-#3857 parser took\n' + + ' `evidence.split(":")[0]` as the filename, flagged 48 of 227 entries and every one\n' + + ' was a false positive — so it could not fail the build, and the one real rot it was\n' + + ' burying (`object.enable.clone`, whose consumer had moved packages) sat unread.\n' + + ' evidence.mts fixed the parse; the list has been empty since, which is what makes\n' + + ' a hit worth stopping for.', + ); } if (report.orphanProofs.length) { console.log(`\n⚠ ${report.orphanProofs.length} unregistered dogfood proof tag(s) — add to proof-registry.mts:`); @@ -659,7 +727,8 @@ if (asJson) { console.log( '\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' + 'registered type is governed or explicitly pending, no ledger row outlives its property, ' + - 'every container inheritance is declared, and all bound high-risk proofs resolve.', + 'every container inheritance is declared, every `live` entry\'s repo-local evidence path ' + + 'resolves, and all bound high-risk proofs resolve.', ); if (report.undrilledChildKeys) { console.log( diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts new file mode 100644 index 0000000000..4bd5efe381 --- /dev/null +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Self-test for the liveness gate's EVIDENCE guard (#5623). +// +// WHY IT RUNS THE REAL SCRIPT. Everything this file asserts is a property of the +// gate as CI invokes it: which finding classes reach `process.exit(1)`, and what +// the summary line claims. Those two live in `check-liveness.mts` itself, not in +// a helper, and re-implementing the decision in a test would pin the copy rather +// than the gate — which is the exact failure #5623 reports one layer down. The +// bug was never that the check could not SEE the rot: it named all five rotted +// pointers, correctly, and exited 0 anyway. A test of `checkEvidence` (there is +// one, in evidence.test.ts) was therefore green throughout. +// +// So each case spawns `check-liveness.mts` the way `pnpm check:liveness` does and +// reads its exit code. `--ledger-root=` lets a case point the walk at a COPY +// of packages/spec/liveness with one pointer broken, so the run has exactly one +// cause for its verdict and no repo file is ever mutated — a crashed test leaves +// the worktree clean. Precedent for spawning a gate in its own test: +// scripts/check-generated-ledger.test.ts. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SPEC = path.resolve(HERE, '../..'); +const GATE = path.join(HERE, 'check-liveness.mts'); +const LEDGERS = path.join(SPEC, 'liveness'); + +// A repo-rooted path shaped exactly like a real pointer (so `evidence.mts` +// extracts it) that this repo has never contained. +const ROTTED = 'packages/plugins/driver-sql/src/sql-driver.ts'; + +function runGate(ledgerRoot?: string): { status: number | null; output: string } { + const require = createRequire(import.meta.url); + const tsx = require.resolve('tsx/cli'); + const argv = [tsx, GATE, ...(ledgerRoot ? [`--ledger-root=${ledgerRoot}`] : [])]; + const r = spawnSync(process.execPath, argv, { cwd: SPEC, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + if (r.error) throw r.error; + return { status: r.status, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +} + +/** Rewrite one property's `evidence` in a copied ledger. */ +function setEvidence(root: string, type: string, prop: string, evidence: string): void { + const file = path.join(root, `${type}.json`); + const ledger = JSON.parse(readFileSync(file, 'utf8')); + ledger.props[prop].evidence = evidence; + writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); +} + +function summaryLine(output: string): string { + return output.split('\n').find((l) => l.startsWith('evidence paths:')) ?? ''; +} + +describe('check:liveness — evidence pointers (#5623)', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-')); + cpSync(LEDGERS, path.join(tmp, 'liveness'), { recursive: true }); + }); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + // The control. Without it, every "exit 1" below would also be satisfied by the + // copy simply not being readable. + it('is green against a verbatim copy of the shipped ledgers', () => { + const { status, output } = runGate(path.join(tmp, 'liveness')); + expect(status, output).toBe(0); + expect(output).toContain('✓ every governed-type property'); + }); + + it('FAILS when a `live` entry cites a repo-local file that is gone', () => { + const root = path.join(tmp, 'broken-local'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence(root, 'query', 'limit', `${ROTTED}:1345`); + + const { status, output } = runGate(root); + // The regression this pins: before #5623 this run printed the finding and + // exited 0, so a directory move could rot an ADR-0087 evidence chain with + // nothing in CI to notice. + expect(status, output).toBe(1); + expect(output).toContain("'live' entr(ies) cite a file that is missing from THIS repo"); + expect(output).toContain(`query/limit → ${ROTTED}`); + // ✗, not ⚠ — the grading is the fix, and the two are one character apart. + expect(output).toMatch(/✗ 1 'live' entr\(ies\) cite a file/); + expect(output).not.toMatch(/⚠ \d+ 'live' entr\(ies\)/); + }); + + it('names EVERY rotted pointer, not just the first', () => { + const root = path.join(tmp, 'broken-many'); + cpSync(LEDGERS, root, { recursive: true }); + for (const prop of ['fields', 'where', 'orderBy', 'limit', 'offset']) { + setEvidence(root, 'query', prop, `${ROTTED} (rotted by the self-test)`); + } + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toMatch(/✗ 5 'live' entr\(ies\) cite a file/); + for (const prop of ['fields', 'where', 'orderBy', 'limit', 'offset']) { + expect(output).toContain(`query/${prop} → ${ROTTED}`); + } + }); + + // THE BOUNDARY. Tightening the local case must not drag the ~101 cross-repo + // attributions in with it: those files are legitimately absent from this + // checkout, and failing on them would make the gate unsatisfiable for every + // property whose consumer is the renderer or the closed cloud runtime. + it('stays green when the missing path is attributed to ANOTHER repo', () => { + const root = path.join(tmp, 'foreign'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence(root, 'query', 'limit', 'objectui: packages/app-shell/src/no-such-file.tsx:12'); + setEvidence(root, 'query', 'offset', 'cloud: packages/ee-runtime/src/also-not-here.ts:9'); + // The closed cloud runtime is repo-ROOTED and still foreign — the shape that + // would break first if the boundary were drawn on the path text alone. + setEvidence(root, 'query', 'orderBy', 'packages/services/service-ai/src/nope.ts:1'); + + const { status, output } = runGate(root); + expect(status, output).toBe(0); + expect(output).not.toContain('cite a file that is missing'); + }); + + it('still fails a local path that shares a string with a foreign clause', () => { + // A realm marker's scope ends at the clause boundary. If it did not, one + // `objectui:` anywhere in an entry would silence the whole entry — a + // one-token opt-out of the gate. + const root = path.join(tmp, 'mixed'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence(root, 'query', 'limit', `objectui: packages/app-shell/src/x.tsx; ${ROTTED}:1345`); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain(`query/limit → ${ROTTED}`); + }); +}); + +describe('check:liveness — the evidence summary line (#5623)', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-sum-')); + }); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it('prints declared and resolved as separate numbers, equal on a green run', () => { + const { status, output } = runGate(); + expect(status, output).toBe(0); + const line = summaryLine(output); + const m = line.match(/^evidence paths: (\d+) repo-local path\(s\) declared by 'live' entries, (\d+) resolved/); + expect(m, line).not.toBeNull(); + expect(m![1]).toBe(m![2]); + // Guards the same degradation evidence.test.ts guards: a parser that extracts + // nothing would make "declared === resolved" vacuously true. + expect(Number(m![1])).toBeGreaterThan(100); + expect(line).not.toContain('MISSING'); + }); + + it('MOVES the resolved count when a pointer rots — the mis-labelled count of #5623', () => { + const root = path.join(tmp, 'liveness'); + cpSync(LEDGERS, root, { recursive: true }); + const green = summaryLine(runGate(root).output); + const declared = Number(green.match(/: (\d+) repo-local/)![1]); + + setEvidence(root, 'query', 'limit', `${ROTTED}:1345`); + const red = summaryLine(runGate(root).output); + + // `declared` is unchanged — the pointer is still DECLARED, it just does not + // resolve. That is precisely why the old line, which printed this number + // under the word "resolved", read 330 both before and after five pointers + // were broken. + expect(red).toContain(`${declared} repo-local path(s) declared`); + expect(red).toContain(`${declared - 1} resolved against this checkout`); + expect(red).toContain('1 MISSING'); + }); + + it('reports foreign attributions separately and never as missing', () => { + const { output } = runGate(); + const line = summaryLine(output); + expect(line).toMatch(/; \d+ attributed to another repo \(objectui \/ cloud — not resolvable here\)\./); + }); +}); From b8321d43e1899d6a4e58e195159f439e9db5960e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:57:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(spec):=20changeset=20=E4=BB=8E=E7=A9=BA?= =?UTF-8?q?=20frontmatter=20=E6=94=B9=E4=B8=BA=20@objectstack/spec=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/spec/liveness/**` 是发布内容(package.json 的 `files` 含 `liveness`;`npm pack --dry-run` 实测 29 个文件入包,含该目录的 README.md),所以本 PR 并非「releases nothing」,route 2 的 `skip-changeset` 与 route 3 的空 changeset 都不适用。 pr-automation.yml 也把空 frontmatter 明确降级为 LAST RESORT:它是 changesets/action 的真实输入,全空集合会走 "All changesets are empty; not creating PR" 分支,发布静默绿着卡住 (#4898 卡住 17.0.0-rc.2)。脚本本身(scripts/)不入包,是 dev-only。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .changeset/liveness-stale-evidence-fails-ci.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/liveness-stale-evidence-fails-ci.md b/.changeset/liveness-stale-evidence-fails-ci.md index f818c3762d..9d912099ed 100644 --- a/.changeset/liveness-stale-evidence-fails-ci.md +++ b/.changeset/liveness-stale-evidence-fails-ci.md @@ -1,7 +1,8 @@ --- +"@objectstack/spec": patch --- -chore(spec): `check:liveness` 的 stale-evidence 从 `⚠` 升级为 `✗` 判红,摘要行不再把「声明数」当作「解析成功数」(#5623) +fix(spec): `check:liveness` 的 stale-evidence 从 `⚠` 升级为 `✗` 判红,摘要行不再把「声明数」当作「解析成功数」(#5623) `live` 判定的语义就是它的 evidence 指针 ——「这个属性有运行时消费者,在这里」。指针指向本仓已不存在的文件时,这条声明就不再可证伪:declared 有,enforced 无。而把它变成这样,只需要一次目录重组或一次改名。 @@ -34,4 +35,4 @@ evidence paths: 330 resolved against this checkout, 101 attributed to another re `check-liveness.test.ts`(新增 8 例)直接 spawn 真脚本断言退出码 —— 这个 bug 从来不是「检查看不见」:它把五条全点名了还是 exit 0,所以只测 `checkEvidence` 的单测全程是绿的。 -纯开发脚本 / CI gate 改动,不改任何运行时或可发布的产物,因此用空 frontmatter changeset(不发布任何包)。当前 `main` 上 330 条本仓路径全部解析成功,该 gate 落地即绿。 +脚本本身(`scripts/`)不进 npm 包,但 `packages/spec/liveness/**` **是发布内容**(`package.json` 的 `files` 里有 `liveness`,`npm pack --dry-run` 实测 29 个文件入包,含该目录的 `README.md`)。一个新开始判红的 gate 必须同步它面向作者的文档,否则下一位作者只能靠撞红的 CI 才知道规则变了 —— 所以 README 里那段新的失败语义也随包发出,按 patch 记账,而不是空 frontmatter(空 changeset 是 `changesets/action` 的真实输入,全空集合会静默绿着卡住发布 —— #4898 卡住 17.0.0-rc.2 的就是这个)。当前 `main` 上 330 条本仓路径全部解析成功,该 gate 落地即绿。