Skip to content

Commit 502564d

Browse files
os-zhuangclaude
andauthored
fix(spec): check:liveness 的 stale-evidence 判红,摘要行的数与词对齐 (#5623) (#6057)
* fix(spec): check:liveness 的 stale-evidence 判红,摘要行的数与词对齐 (#5623) `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=<dir>`,自测据此把真 gate 跑在只坏了一条指针的 ledger 副本上,不改动仓库任何文件。 以前是 ⚠ 不是对本仓路径的有意宽容,是解析器时代的遗留:#3857 之前 `evidence.split(':')[0]` 在 227 条里报 48 条、全是误报。同族的 check-empty-state 对 rotted-evidence 一直是 ✗ + exit 1。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 * fix(spec): changeset 从空 frontmatter 改为 @objectstack/spec patch `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61122e8 commit 502564d

4 files changed

Lines changed: 310 additions & 9 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `check:liveness` 的 stale-evidence 从 `⚠` 升级为 `✗` 判红,摘要行不再把「声明数」当作「解析成功数」(#5623)
6+
7+
`live` 判定的语义就是它的 evidence 指针 ——「这个属性有运行时消费者,在这里」。指针指向本仓已不存在的文件时,这条声明就不再可证伪:declared 有,enforced 无。而把它变成这样,只需要一次目录重组或一次改名。
8+
9+
实测(`origin/main`,把 `packages/spec/liveness/query.json` 的 5 条 evidence 路径指回迁移前的 `packages/plugins/driver-sql/...`):
10+
11+
```
12+
evidence paths: 330 resolved against this checkout, 101 attributed to another repo (…)
13+
14+
⚠ 5 'live' entr(ies) cite a missing file:
15+
query/fields → packages/plugins/driver-sql/src/sql-driver.ts
16+
…
17+
```
18+
19+
退出码 **0**。五条断链逐条点名了,CI 一路绿灯 —— 而且摘要行里的 `330` 一动没动,因为它数的是 local 路径**总数**,不是解析成功数,"resolved" 这个词是失真的。
20+
21+
**为什么以前是 `⚠`,以及为什么现在不该是。** 这不是对本仓路径的有意宽容,是解析器时代的遗留:#3857 之前的检查取 `evidence.split(':')[0]` 当文件名,227 条里报 48 条、**全是误报**,那种情况下它当然不能判红。`evidence.mts` 修好了解析(realm marker 归属 + 仓根路径提取),此后这份清单一直是空的 —— 恰恰是这一点让「有命中」重新变成信号。当时被那 48 条噪声埋掉的唯一一条真腐烂(`object.enable.clone`,消费者从 `@objectstack/objectql` 搬到了 `@objectstack/metadata-protocol`),就是宽容的实际代价。
22+
23+
同族的旁证:同一目录下的 `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 那一段一个字都没有。
24+
25+
**改动**
26+
27+
1. **`live` 条目引用本仓缺失文件 → `✗` 判红**,并给出三条修法(仓内搬家就改指针并补 `verifiedAt`;搬去别的仓就加 realm marker;消费者真没了就按 ADR-0049 重新判定,而不是随手指向一个看着像的幸存者)。
28+
**边界不变**:cross-repo attribution(`objectui:` / `cloud:` / `packages/services/service-ai/…`,当前 101 条)只计数、不解析,永远不判红 —— `checkEvidence` 只对 local 桶做存在性检查,这个分界是结构性的,不是靠约定。
29+
2. **摘要行改成两个数**,而不是二选一:
30+
```
31+
evidence paths: 330 repo-local path(s) declared by 'live' entries, 330 resolved against this checkout; 101 attributed to another repo (…)
32+
```
33+
两个数都留着是有原因的:"declared" 是 #3857 留下的解析健康度信号(有单测防止解析器退化成「什么都提不出来」而假绿),"resolved" 是判定。绿的时候两者相等 —— 这正是当初只印前一个会被读成通过的原因。有断链时行尾追加 `, N MISSING`。
34+
3. 新增 `--ledger-root=<dir>`:让 gate 读 `packages/spec/liveness` 的一份副本。自测据此把真 gate 跑在「只坏了一条指针」的 ledger 副本上,不改动仓库里任何文件。
35+
36+
`check-liveness.test.ts`(新增 8 例)直接 spawn 真脚本断言退出码 —— 这个 bug 从来不是「检查看不见」:它把五条全点名了还是 exit 0,所以只测 `checkEvidence` 的单测全程是绿的。
37+
38+
脚本本身(`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 落地即绿。

‎packages/spec/liveness/README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,18 @@ checkout. Prose around the paths is fine and encouraged — `packages/spec/src/s
7474
(mergeActionsIntoObjects stable-sorts each group)` resolves the path and ignores
7575
the parenthetical.
7676

77-
**A path in another repo must say so**, or the gate will report it as rot:
77+
**A repo-local path that does not resolve FAILS CI** (`✗`, exit 1 — since #5623;
78+
it was a non-failing `⚠` before, calibrated for the parser bug described in the
79+
note below rather than for the parser we have). A `live` verdict *is* its
80+
evidence pointer, so a pointer into thin air is a claim nothing can falsify — and
81+
a directory move or a rename is the whole cost of getting there. Repair it, or,
82+
if the consumer is genuinely gone, re-classify the property under ADR-0049
83+
enforce-or-remove instead of repointing at a plausible survivor.
84+
85+
**A path in another repo must say so**, or the gate will report it as rot —
86+
attributed paths are counted and never resolved, so they cannot fail this check
87+
(that boundary is deliberate: ~101 entries cite the renderer repo or the closed
88+
cloud runtime, whose files are legitimately absent here):
7889

7990
```jsonc
8091
"evidence": "objectui: packages/app-shell/src/views/RecordDetailView.tsx:573"

‎packages/spec/scripts/liveness/check-liveness.mts‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@
4141
// (see proof-registry.mts), a `live` classification MUST carry a valid proof —
4242
// the file must exist and declare the `@proof: <id>` tag. CI fails otherwise.
4343
//
44+
// EVIDENCE POINTERS (ADR-0087): a `live` verdict IS its evidence pointer — "this
45+
// property has a runtime consumer, here it is". A cited path that is repo-rooted
46+
// and attributed to THIS repo must resolve against this checkout, or CI fails:
47+
// an unresolvable pointer makes the claim unfalsifiable, and a directory move or
48+
// a rename is all it takes (see evidence.mts). Cross-repo attribution
49+
// (`objectui: …`, `cloud: …`, `packages/services/service-ai/…`) is counted, not
50+
// resolved — those files are legitimately absent here. This was a ⚠ until #5623,
51+
// for one historical reason: the pre-#3857 `evidence.split(':')[0]` parser
52+
// flagged 48 of 227 entries with a ~100% false-positive rate, so failing on it
53+
// would have failed every build. The parse fix is what turned a hit into signal.
54+
//
4455
// RE-VERIFICATION CLOCK (`verifiedAt`): a ledger entry is a claim with a
4556
// timestamp, and code moves under it in BOTH directions — `flow.status` (#3711)
4657
// and `action.undoable` (#3714) were both understated by entries that were
@@ -56,6 +67,10 @@
5667
// tsx check-liveness.mts --stale-verification # print the re-verification worklist
5768
// tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold
5869
// tsx check-liveness.mts --undrilled # print the undrilled-container worklist
70+
// tsx check-liveness.mts --ledger-root=<dir> # read the ledgers from <dir> instead of
71+
// # packages/spec/liveness — how the self-test
72+
// # runs the REAL gate against a mutated copy
73+
// # of the real ledgers without touching them
5974

6075
process.env.OS_EAGER_SCHEMAS = '1';
6176

@@ -93,7 +108,22 @@ import {
93108
const here = dirname(fileURLToPath(import.meta.url));
94109
const specRoot = resolve(here, '../..'); // packages/spec
95110
const repoRoot = resolve(specRoot, '../..');
96-
const ledgerRoot = join(specRoot, 'liveness');
111+
112+
const args = process.argv.slice(2);
113+
114+
// `--ledger-root=<dir>` points the walk at a COPY of packages/spec/liveness.
115+
// It exists because the evidence gate below now fails the build, and a gate that
116+
// fails is only worth as much as the proof that it fails — which needs a rotted
117+
// pointer to fail ON. Committing one to a shipped ledger is not an option (it
118+
// would fail every other run), and mutating a tracked file mid-test leaves the
119+
// worktree dirty when the test crashes. So the self-test copies the real ledgers
120+
// to a temp dir, breaks exactly one pointer there, and runs THIS script — same
121+
// code path CI runs, no repo state touched. Everything else still resolves
122+
// against the real repoRoot, so the resulting exit 1 has exactly one cause.
123+
const ledgerRootArg = args.find((a) => a.startsWith('--ledger-root='));
124+
const ledgerRoot = ledgerRootArg
125+
? resolve(ledgerRootArg.slice('--ledger-root='.length))
126+
: join(specRoot, 'liveness');
97127

98128
// Governed metadata types, rolled out highest-frequency / highest-risk first.
99129
// (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.)
@@ -262,7 +292,6 @@ function loadLedger(type: string): any {
262292
}
263293

264294
// ---- dump mode ----
265-
const args = process.argv.slice(2);
266295
const dumpIdx = args.indexOf('--dump');
267296
if (dumpIdx !== -1) {
268297
const type = args[dumpIdx + 1];
@@ -300,8 +329,15 @@ const report: any = {
300329
deferredContainers: [] as string[], // containers whose subtree IS classified elsewhere — resolved, not believed
301330
deferredChildKeys: 0, // how many child keys those resolved deferrals actually cover
302331
verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist
303-
evidenceLocal: 0, // repo-rooted evidence paths actually resolved against this checkout
304-
evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here
332+
// The three evidence counters, and the distinction between the first two is
333+
// the whole point: `evidenceLocal` is how many repo-rooted paths `live` entries
334+
// DECLARE, `evidenceMissing` is how many of those do not exist here. The
335+
// summary line used to print `evidenceLocal` under the word "resolved", so
336+
// breaking five pointers left the count at 330 and the run still said
337+
// "330 resolved" (#5623). Count and word now agree.
338+
evidenceLocal: 0, // repo-rooted evidence paths attributed to THIS repo — DECLARED, not yet proven
339+
evidenceMissing: 0, // ...of which this many do not exist here (=== staleEvidence.length) — FAILS the gate
340+
evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here, never failed
305341
};
306342

307343
// 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
324360
const ev = checkEvidence(led.evidence, (p) => existsSync(join(repoRoot, p)));
325361
report.evidenceForeign += ev.foreign.length;
326362
report.evidenceLocal += ev.local.length;
363+
report.evidenceMissing += ev.missing.length;
327364
for (const miss of ev.missing) report.staleEvidence.push(`${type}/${path} → ${miss}`);
328365
}
329366
// ── ADR-0054 prove-it-runs ──
@@ -507,6 +544,11 @@ const totalProofFailures = report.proofErrors.length + report.proofMissing.lengt
507544
const failed =
508545
totalUnclassified > 0 ||
509546
totalProofFailures > 0 ||
547+
// A `live` entry whose repo-local evidence path is gone. Red since #5623 — the
548+
// ⚠ it replaces was calibrated for the false-positive era, not for the parser
549+
// that now resolves 330 paths and reports zero. Cross-repo attribution never
550+
// reaches this list: checkEvidence only resolves the LOCAL bucket.
551+
report.staleEvidence.length > 0 ||
510552
report.orphanEntries.length > 0 ||
511553
report.verification.errors.length > 0 ||
512554
report.ungoverned.length > 0 ||
@@ -524,13 +566,39 @@ if (asJson) {
524566
}
525567
const boundClasses = HIGH_RISK_CLASSES.filter((c) => c.bound).map((c) => c.label);
526568
console.log(`\nprove-it-runs (ADR-0054): proof REQUIRED for bound high-risk classes — ${boundClasses.join(', ') || 'none'}`);
569+
// Two numbers, because one cannot carry both facts. "Declared" is the
570+
// extraction-health signal (#3857's unit test guards it against degrading to
571+
// "extracts nothing"); "resolved" is the verdict. They are equal on a green
572+
// run, which is exactly why printing only the first read as a pass.
527573
console.log(
528-
`\nevidence paths: ${report.evidenceLocal} resolved against this checkout, ` +
529-
`${report.evidenceForeign} attributed to another repo (objectui / cloud — not resolvable here).`,
574+
`\nevidence paths: ${report.evidenceLocal} repo-local path(s) declared by 'live' entries, ` +
575+
`${report.evidenceLocal - report.evidenceMissing} resolved against this checkout` +
576+
(report.evidenceMissing ? `, ${report.evidenceMissing} MISSING` : '') +
577+
`; ${report.evidenceForeign} attributed to another repo (objectui / cloud — not resolvable here).`,
530578
);
531579
if (report.staleEvidence.length) {
532-
console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file:`);
580+
console.log(`\n✗ ${report.staleEvidence.length} 'live' entr(ies) cite a file that is missing from THIS repo:`);
533581
report.staleEvidence.forEach((s: string) => console.log(` ${s}`));
582+
console.log(
583+
'\n A `live` verdict IS its evidence pointer — "this property has a runtime consumer,\n' +
584+
' here it is". When the cited file is gone from this checkout the claim is no longer\n' +
585+
' falsifiable: declared, but nothing enforces that anything still reads the property,\n' +
586+
' and a directory move or a rename is the whole cost of getting there.\n\n' +
587+
' Three repairs, and picking the right one is the work:\n' +
588+
' • the consumer MOVED inside this repo → repoint the path, and stamp `verifiedAt`\n' +
589+
' while you have the call graph open;\n' +
590+
' • the consumer moved to ANOTHER repo → say so with a realm marker\n' +
591+
' (`objectui: packages/app-shell/…`, `cloud: …`). Attributed paths are counted,\n' +
592+
' never resolved, and never fail here — that boundary is deliberate;\n' +
593+
' • the consumer is GONE → the verdict is not `live` any more. Re-classify under\n' +
594+
' ADR-0049 enforce-or-remove instead of repointing at a plausible survivor.\n\n' +
595+
' This was a ⚠ until #5623 for one reason: the pre-#3857 parser took\n' +
596+
' `evidence.split(":")[0]` as the filename, flagged 48 of 227 entries and every one\n' +
597+
' was a false positive — so it could not fail the build, and the one real rot it was\n' +
598+
' burying (`object.enable.clone`, whose consumer had moved packages) sat unread.\n' +
599+
' evidence.mts fixed the parse; the list has been empty since, which is what makes\n' +
600+
' a hit worth stopping for.',
601+
);
534602
}
535603
if (report.orphanProofs.length) {
536604
console.log(`\n⚠ ${report.orphanProofs.length} unregistered dogfood proof tag(s) — add to proof-registry.mts:`);
@@ -659,7 +727,8 @@ if (asJson) {
659727
console.log(
660728
'\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' +
661729
'registered type is governed or explicitly pending, no ledger row outlives its property, ' +
662-
'every container inheritance is declared, and all bound high-risk proofs resolve.',
730+
'every container inheritance is declared, every `live` entry\'s repo-local evidence path ' +
731+
'resolves, and all bound high-risk proofs resolve.',
663732
);
664733
if (report.undrilledChildKeys) {
665734
console.log(

0 commit comments

Comments
 (0)