@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +5 -3
- package/lib/api.js +211 -5
- package/lib/client.js +1240 -354
- package/lib/config.js +27 -0
- package/lib/dream/decisions.js +39 -1
- package/lib/dream/sleep.js +146 -6
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +123 -8
- package/lib/hot-memory.js +14 -2
- package/lib/index.js +31 -1
- package/lib/inject.js +14 -2
- package/lib/lang.js +11 -0
- package/lib/service.js +76 -4
- package/lib/settings.js +16 -1
- package/lib/store.js +238 -5
- package/lib/wiki/claims.js +267 -0
- package/lib/wiki/queries.js +168 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1.js +138 -31
- package/package.json +15 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api.js +211 -5
- package/src/config.js +27 -0
- package/src/dream/decisions.js +39 -1
- package/src/dream/sleep.js +146 -6
- package/src/dream/triage.js +139 -0
- package/src/dream.js +123 -8
- package/src/hot-memory.js +14 -2
- package/src/index.js +31 -1
- package/src/inject.js +14 -2
- package/src/lang.js +11 -0
- package/src/service.js +76 -4
- package/src/settings.js +16 -1
- package/src/store.js +238 -5
- package/src/wiki/claims.js +267 -0
- package/src/wiki/queries.js +168 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1.js +138 -31
- package/test/api.test.js +11 -1
- package/test/client.test.js +275 -34
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/contrast.test.js +124 -12
- package/test/dream-baseline-clamp.test.js +174 -0
- package/test/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -0
- package/test/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -0
- package/test/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep.test.js +37 -0
- package/test/wiki-api.test.js +110 -3
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +121 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1.test.js +226 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// dsh-mneme/src/wiki/claims.js
|
|
2
|
+
// Claim 粒度的失效判定——借自 openwiki 的 Grounded Claims 设计:每条结论挂着「哪个
|
|
3
|
+
// 文件、哪几行」的证据,代码一动就先算出「哪几条结论失准」,而不是把整个仓库判成
|
|
4
|
+
// 过期、把全部语义段重跑一遍。
|
|
5
|
+
//
|
|
6
|
+
// 为什么值得做:choro_claims 早就存了 source_path / start_line / end_line(见 store.js
|
|
7
|
+
// 的 choro_claims 表定义),三列本来就是为这件事准备的;可 t0.js 重跑时整页 claims 会被
|
|
8
|
+
// 清空(replaceChoroGraph 的 pages[].claims = []),于是改一行代码就让所有页的语义段
|
|
9
|
+
// 变成无主孤儿。本模块把「整仓过期」降成「哪页哪条」,代价是一次 git diff 加几十行
|
|
10
|
+
// 纯计算:零模型、零网络、零写入。
|
|
11
|
+
//
|
|
12
|
+
// 只算「相对上次生成基线改了什么」,不判断代码本体——结论要留给读它的人。
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
// git 对「含非 ASCII / 控制字符」的路径会加双引号并按 C 风格转义(core.quotepath 默认
|
|
16
|
+
// 就是开的),例如中文名会写成 "a/\344\270\255\346\226\207.js"。只脱引号不解转义的话,
|
|
17
|
+
// 归一出来的字符串永远对不上 choro_claims.source_path,这些文件上的结论就再也判不成
|
|
18
|
+
// touched —— **漏判**比误判更难发现,因为它看起来一切正常。
|
|
19
|
+
const GIT_PATH_ESCAPES = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", '"': '"', "\\": "\\" };
|
|
20
|
+
|
|
21
|
+
function unescapeQuotedGitPath(s) {
|
|
22
|
+
if (!s.includes("\\")) return s;
|
|
23
|
+
const bytes = [];
|
|
24
|
+
const pushStr = (str) => { for (const b of Buffer.from(str, "utf8")) bytes.push(b); };
|
|
25
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
26
|
+
const ch = s[i];
|
|
27
|
+
if (ch !== "\\" || i === s.length - 1) { pushStr(ch); continue; }
|
|
28
|
+
const octal = /^[0-7]{3}/.exec(s.slice(i + 1));
|
|
29
|
+
if (octal) { bytes.push(parseInt(octal[0], 8) & 0xff); i += 3; continue; }
|
|
30
|
+
const escaped = GIT_PATH_ESCAPES[s[i + 1]];
|
|
31
|
+
if (escaped !== undefined) { pushStr(escaped); i += 1; continue; }
|
|
32
|
+
pushStr(ch);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.from(bytes).toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 路径归一:choro_claims.source_path 存的是仓库相对路径(正斜杠);git 侧也是正斜杠,
|
|
38
|
+
// 但 `--- a/x` `+++ b/x` 带前缀,可能需要去掉。转义只出现在被引号包起来的那种写法里,
|
|
39
|
+
// 所以解转义严格跟着「这次是否脱过引号」走——不能对未加引号的路径无条件解,
|
|
40
|
+
// 否则 Windows 风格的反斜杠会被当成转义序列吃掉。
|
|
41
|
+
function normDiffPath(p) {
|
|
42
|
+
let s = String(p ?? "").trim();
|
|
43
|
+
const quoted = s.length >= 2 && s.startsWith('"') && s.endsWith('"');
|
|
44
|
+
if (quoted) s = unescapeQuotedGitPath(s.slice(1, -1));
|
|
45
|
+
if (s.startsWith("a/") || s.startsWith("b/")) s = s.slice(2);
|
|
46
|
+
return s.replace(/\\/g, "/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 解析 `git diff --unified=0 <baseline> HEAD` 的文本。
|
|
51
|
+
*
|
|
52
|
+
* 只认 hunk 头 `@@ -a,b +c,d @@`:不解析正文,正文里带 `@@` 的代码行不会干扰,
|
|
53
|
+
* 因为 hunk 头必须匹配完整形状。两个计数都要——旧侧计数是新旧行坐标的唯一来源,
|
|
54
|
+
* claims 的行号记在**旧侧**(写 claim 时那一版就是 HEAD)。
|
|
55
|
+
*
|
|
56
|
+
* 不传 -M 还不够:git 在 `--unified=0` 下**仍会**自动开重命名检测(实测输出
|
|
57
|
+
* `similarity index 100% / rename from / rename to`,既不出 hunk 也不进 /dev/null),
|
|
58
|
+
* 于是重命名后旧路径上的 claim 会被判成「没事」。必须显式 `--no-renames`,让重命名
|
|
59
|
+
* 按「删旧 + 增新」处理:旧路径上的 claim 落进 deleted 是对的(锚点跟着文件走了,得
|
|
60
|
+
* 重新指),新路径没有 claim 自然也不会误报。
|
|
61
|
+
*
|
|
62
|
+
* 返回 { files: Map<path, [{oldStart, oldCount, newStart, newCount}]>, deleted: string[] }。
|
|
63
|
+
*/
|
|
64
|
+
export function parseDiffRanges(diffText) {
|
|
65
|
+
const files = new Map();
|
|
66
|
+
const deleted = [];
|
|
67
|
+
let livePath = null;
|
|
68
|
+
let oldPath = null;
|
|
69
|
+
for (const line of String(diffText ?? "").split("\n")) {
|
|
70
|
+
if (line.startsWith("diff --git ")) { livePath = null; oldPath = null; continue; }
|
|
71
|
+
if (line.startsWith("--- ")) { oldPath = normDiffPath(line.slice(4)); continue; }
|
|
72
|
+
if (line.startsWith("+++ ")) {
|
|
73
|
+
const raw = line.slice(4).trim();
|
|
74
|
+
if (raw === "/dev/null") {
|
|
75
|
+
// 整文件删除:claims 指向它的全部作废(没有出处就没有可校验的结论)。
|
|
76
|
+
if (oldPath) deleted.push(oldPath);
|
|
77
|
+
livePath = null;
|
|
78
|
+
} else {
|
|
79
|
+
livePath = normDiffPath(raw);
|
|
80
|
+
if (!files.has(livePath)) files.set(livePath, []);
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
85
|
+
if (!m || livePath === null) continue;
|
|
86
|
+
files.get(livePath).push({
|
|
87
|
+
oldStart: Number(m[1]),
|
|
88
|
+
oldCount: m[2] === undefined ? 1 : Number(m[2]),
|
|
89
|
+
newStart: Number(m[3]),
|
|
90
|
+
newCount: m[4] === undefined ? 1 : Number(m[4]),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { files, deleted };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 取基线到当前 HEAD 的 diff。基线不在仓库里(force-push / 浅克隆 / 换机重拉)时
|
|
98
|
+
* 不猜:回 { ok: false, reason },调用方据此说「基线认不出了,建议重建」而不是
|
|
99
|
+
* 拿空 diff 报「全部结论都还准」——空 diff 与「算不出来」在页面上是两句话。
|
|
100
|
+
*/
|
|
101
|
+
export function diffSince(repoRoot, baseline, head) {
|
|
102
|
+
const clean = (v) => /^[0-9a-f]{7,40}$/i.test(String(v ?? "").trim());
|
|
103
|
+
if (!clean(baseline) || !clean(head)) return { ok: false, reason: "baseline-unreadable" };
|
|
104
|
+
try {
|
|
105
|
+
const text = execFileSync(
|
|
106
|
+
"git",
|
|
107
|
+
["diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-renames", `${baseline}..${head}`],
|
|
108
|
+
// --no-renames 是必需的,不是保险:git 会按配置自动开重命名检测(实测在
|
|
109
|
+
// --unified=0 下也开),一旦识别成 rename 就既不产生 hunk、也不产生 /dev/null,
|
|
110
|
+
// 旧路径上的 claim 会被静默判成 untouched。见本函数上方 parseDiffRanges 的说明。
|
|
111
|
+
// stdio 显式接管 stderr:execFileSync 默认把子进程 stderr 直接漏到父进程,
|
|
112
|
+
// 「不是 git 仓库」那条几十行的 usage 会污染面板日志,也会在测试输出里刷屏。
|
|
113
|
+
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }
|
|
114
|
+
);
|
|
115
|
+
return { ok: true, ...parseDiffRanges(text) };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// 两种情况在这里合流:`<基线>..HEAD` 里有 git 认不出的 rev(force-push 后
|
|
118
|
+
// 旧基线消失、浅克隆截断历史),或这个目录根本不是 git 仓库。对调用方是同一件
|
|
119
|
+
// 事:**算不出来**。别把 exit code 当 reason 抛给面板——面板要说的是人话。
|
|
120
|
+
return { ok: false, reason: "diff-failed", detail: String(err?.stderr ?? err?.message ?? err).trim().slice(0, 200) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normPath(p) {
|
|
125
|
+
return String(p ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 逐条 claim 判「还准不准」。
|
|
130
|
+
*
|
|
131
|
+
* 四档理由,全部能从 diff 与三列证据机械算出:
|
|
132
|
+
* - deleted 证据文件在基线之后被删了,这条结论没有出处了。
|
|
133
|
+
* - touched 改动的行与 claim 的行区间相交,结论本身可能已经不对。
|
|
134
|
+
* - shifted claim 区间没被碰,但它上方有增删,行号整体偏移——内容还在,
|
|
135
|
+
* 锚点要重新指。刻意与 touched 分开:混成一个「失效」,面板就会
|
|
136
|
+
* 对着一堆只是行号错位的结论喊「要重写」,喊几次就没人信了。
|
|
137
|
+
* - unverifiable 所属文件改过,可这条 claim 当初就没记行号,无法定位到具体一行。
|
|
138
|
+
*
|
|
139
|
+
* 输入 diff 为 { files: Map, deleted: string[] };输出与输入等长,未受影响的
|
|
140
|
+
* reason 为 null(调用方自行过滤)。
|
|
141
|
+
*/
|
|
142
|
+
export function assessClaims(claims, diff) {
|
|
143
|
+
const files = diff?.files ?? new Map();
|
|
144
|
+
const deleted = new Set(diff?.deleted ?? []);
|
|
145
|
+
return (claims ?? []).map((claim) => {
|
|
146
|
+
const path = normPath(claim.sourcePath);
|
|
147
|
+
if (path === "") return { ...claim, reason: null };
|
|
148
|
+
if (deleted.has(path)) return { ...claim, reason: "deleted" };
|
|
149
|
+
const hunks = files.get(path);
|
|
150
|
+
if (!hunks || hunks.length === 0) return { ...claim, reason: null };
|
|
151
|
+
const start = Number.isInteger(claim.startLine) ? claim.startLine : null;
|
|
152
|
+
const end = Number.isInteger(claim.endLine) ? claim.endLine : start;
|
|
153
|
+
if (start === null || start < 1) return { ...claim, reason: "unverifiable" };
|
|
154
|
+
const sorted = [...hunks].sort((a, b) => a.oldStart - b.oldStart);
|
|
155
|
+
let shift = 0;
|
|
156
|
+
for (const h of sorted) {
|
|
157
|
+
const hOldEnd = h.oldCount === 0 ? h.oldStart : h.oldStart + h.oldCount - 1;
|
|
158
|
+
// 纯插入(`@@ -a,0 +c,d @@`,oldCount === 0)没有「旧行区间」,但它插在旧第 a 行
|
|
159
|
+
// 之后、第 a+1 行之前。落进 claim 区间内部(a >= start 且 a < end)就是真碰到了,
|
|
160
|
+
// 与「删除/替换」一样算 touched;插在 claim 首行之前只是把它推下去,仍算 shifted。
|
|
161
|
+
// 老写法 `h.oldCount > 0 && ...` 让纯插入永远不算相交——多行结论被插进新行时,
|
|
162
|
+
// 结论原文已被改变却报「没事」。
|
|
163
|
+
const overlaps = h.oldCount > 0
|
|
164
|
+
? start <= hOldEnd && end >= h.oldStart
|
|
165
|
+
: h.oldStart >= start && h.oldStart < end;
|
|
166
|
+
if (overlaps) return { ...claim, reason: "touched", shift };
|
|
167
|
+
if (hOldEnd < start) shift += h.newCount - h.oldCount;
|
|
168
|
+
}
|
|
169
|
+
return shift === 0 ? { ...claim, reason: null } : { ...claim, reason: "shifted", shift };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 仓库级汇总:逐条判定 → 「哪几页、几条、因为什么」。
|
|
175
|
+
*
|
|
176
|
+
* claims 每项需带 pageSlug;pages 每项 { slug, title, hasT1, verifiedBaseline,
|
|
177
|
+
* verifiedAt, diffBaseline, baselineApprox, baselineUnknown, anchorStale }。
|
|
178
|
+
*
|
|
179
|
+
* 为什么 pages 要带验证基线、为什么还要 diffByPage:claims 的行号是**各自那页被
|
|
180
|
+
* 富化的那一刻**记下的。全仓只有一个 git_baseline(T0 的时点),拿它当所有 claims
|
|
181
|
+
* 的比对起点,会把「T1 之前就发生、claim 早已算进去」的改动误报成 touched——页越晚
|
|
182
|
+
* 富化越容易误报。所以每页按自己的 verifiedBaseline 分组各算一次 diff。
|
|
183
|
+
*
|
|
184
|
+
* hasT1 是页面正文里有 T1 段标记的页——这类页「正文有语义段、库里零条 claims」是
|
|
185
|
+
* t1.js 落盘非原子留下的残局(文件先落盘、DB 后写,中途崩了就成这样)。单独记进
|
|
186
|
+
* inconsistent:不修它的话这页永远「看起来没有可校验的证据」,一次都不会被标成需要复核。
|
|
187
|
+
*
|
|
188
|
+
* diffByPage(slug) 三态:diff 对象 = 算过了;null = 这一页没有可用的起点(判不了)。
|
|
189
|
+
* 「判不了」的结论一条都不标,页面用 baselineUnknown / anchorStale 两种不同的说法交代。
|
|
190
|
+
*/
|
|
191
|
+
export function summarizeClaims(claims, diff, { pages = [], diffByPage = null } = {}) {
|
|
192
|
+
const assessed = diffByPage
|
|
193
|
+
? (claims ?? []).flatMap((claim) => assessClaims([claim], diffByPage(claim.pageSlug)))
|
|
194
|
+
: assessClaims(claims, diff);
|
|
195
|
+
const bySlug = new Map();
|
|
196
|
+
for (const page of pages) {
|
|
197
|
+
bySlug.set(page.slug, {
|
|
198
|
+
slug: page.slug,
|
|
199
|
+
title: page.title ?? page.slug,
|
|
200
|
+
hasT1: Boolean(page.hasT1),
|
|
201
|
+
verifiedBaseline: page.verifiedBaseline ?? null,
|
|
202
|
+
verifiedAt: page.verifiedAt ?? null,
|
|
203
|
+
// 实际拿来跑 diff 的起点:新数据就是 verifiedBaseline;老数据(本功能之前富化)
|
|
204
|
+
// 退到全仓基线,baselineApprox 标记「这不是当真的验证点,是保底起点」。
|
|
205
|
+
diffBaseline: page.diffBaseline ?? page.verifiedBaseline ?? null,
|
|
206
|
+
baselineApprox: Boolean(page.baselineApprox),
|
|
207
|
+
baselineUnknown: Boolean(page.baselineUnknown),
|
|
208
|
+
anchorStale: Boolean(page.anchorStale),
|
|
209
|
+
claims: 0,
|
|
210
|
+
atRisk: [],
|
|
211
|
+
inconsistent: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
for (const claim of assessed) {
|
|
215
|
+
const slug = claim.pageSlug;
|
|
216
|
+
if (!bySlug.has(slug)) {
|
|
217
|
+
bySlug.set(slug, { slug, title: claim.pageTitle ?? slug, hasT1: false, claims: 0, atRisk: [], inconsistent: false });
|
|
218
|
+
}
|
|
219
|
+
const bucket = bySlug.get(slug);
|
|
220
|
+
bucket.claims += 1;
|
|
221
|
+
if (!claim.reason) continue;
|
|
222
|
+
bucket.atRisk.push({
|
|
223
|
+
claimId: claim.id ?? null,
|
|
224
|
+
statement: claim.statement,
|
|
225
|
+
kind: claim.kind ?? null,
|
|
226
|
+
sourcePath: claim.sourcePath,
|
|
227
|
+
startLine: claim.startLine ?? null,
|
|
228
|
+
endLine: claim.endLine ?? null,
|
|
229
|
+
reason: claim.reason,
|
|
230
|
+
shift: claim.shift ?? 0,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
for (const bucket of bySlug.values()) {
|
|
234
|
+
bucket.inconsistent = bucket.hasT1 && bucket.claims === 0;
|
|
235
|
+
// 零条结论的页谈不上「哪次验证的」——骨架页不该因为没富化过就被标成「判不了」。
|
|
236
|
+
if (bucket.claims === 0) bucket.baselineUnknown = false;
|
|
237
|
+
}
|
|
238
|
+
const list = [...bySlug.values()].sort((a, b) => {
|
|
239
|
+
// 有风险的排前面,然后依次是:锚点对不上账的、起点认不出的、本来就是空的、
|
|
240
|
+
// 干净的。依次递减的严重度,是为了让「要动手」的永远在第一屏。
|
|
241
|
+
const rank = (p) =>
|
|
242
|
+
p.atRisk.length > 0 ? 0
|
|
243
|
+
: p.inconsistent ? 1
|
|
244
|
+
: p.anchorStale ? 2
|
|
245
|
+
: p.baselineUnknown ? 3
|
|
246
|
+
: 4;
|
|
247
|
+
return rank(a) - rank(b) || a.slug.localeCompare(b.slug);
|
|
248
|
+
});
|
|
249
|
+
const flat = list.flatMap((p) => p.atRisk);
|
|
250
|
+
const count = (reason) => flat.filter((c) => c.reason === reason).length;
|
|
251
|
+
return {
|
|
252
|
+
pages: list,
|
|
253
|
+
totals: {
|
|
254
|
+
pages: list.length,
|
|
255
|
+
claims: assessed.length,
|
|
256
|
+
atRiskClaims: flat.length,
|
|
257
|
+
atRiskPages: list.filter((p) => p.atRisk.length > 0).length,
|
|
258
|
+
touched: count("touched"),
|
|
259
|
+
shifted: count("shifted"),
|
|
260
|
+
deleted: count("deleted"),
|
|
261
|
+
unverifiable: count("unverifiable"),
|
|
262
|
+
inconsistentPages: list.filter((p) => p.inconsistent).length,
|
|
263
|
+
baselineUnknownPages: list.filter((p) => p.baselineUnknown).length,
|
|
264
|
+
anchorStalePages: list.filter((p) => p.anchorStale).length,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
package/lib/wiki/queries.js
CHANGED
|
@@ -13,7 +13,10 @@ import { join, resolve, sep } from "node:path";
|
|
|
13
13
|
// 面板侧不另造一套判据——两套判据迟早会对同一仓库给出两个答案。
|
|
14
14
|
import { gitHead } from "./t0.js";
|
|
15
15
|
import { loadPlanFromRepo } from "./plan.js";
|
|
16
|
-
import { TEMPLATE_VERSION } from "./render.js";
|
|
16
|
+
import { TEMPLATE_VERSION, T1_SECTION_START } from "./render.js";
|
|
17
|
+
// claim 失效判定的纯函数(借 openwiki 的 Grounded Claims):解析 diff + 逐条比对
|
|
18
|
+
// 三列证据。放独立模块是因为它零 DB、零 IO(除 diffSince 那一次 git),可单测。
|
|
19
|
+
import { diffSince, summarizeClaims } from "./claims.js";
|
|
17
20
|
|
|
18
21
|
// 页正文去 frontmatter / CHORO 标记行后作为嵌入文本(P40:嵌页级全文)。
|
|
19
22
|
function pageEmbedText(content) {
|
|
@@ -94,10 +97,27 @@ export function createChoroQueries({ store }) {
|
|
|
94
97
|
// legacy = 这个仓库里有页面是用旧模板(或 M0 的 NULL)渲染的:重跑一次 T0 就
|
|
95
98
|
// 会把它们升级成当前模板,面板据此提示「建议重建」而不是让用户自己比对版本。
|
|
96
99
|
legacy: legacyPages(row.id) > 0,
|
|
100
|
+
// enrichedPages:已经富化过(页面里有 T1 语义段、库里有 claims)的页数。
|
|
101
|
+
// 自动刷新要看它:这类页重跑 T0 会退回骨架,所以那条路必须先问一句。
|
|
102
|
+
enrichedPages: enrichedPageCount(row.id),
|
|
97
103
|
lastRunAt: row.updated_at ?? null
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 已富化的页数。判据取库里的 status(T1 成功后会写 enriched),而不是读盘找
|
|
109
|
+
* T1 标记——这是条给面板列表用的轻查询,几百页也不该逐文件 readFileSync。
|
|
110
|
+
* 两边可能短暂不一致(例如有人手改了 MD),但「要不要提醒」这种提示用途,
|
|
111
|
+
* 库里的账本就是最合适的真源;真正判断「会丢什么」的仍是 T0 的 collectT1Loss(读盘)。
|
|
112
|
+
*/
|
|
113
|
+
function enrichedPageCount(repoId) {
|
|
114
|
+
const hit = db.prepare(
|
|
115
|
+
`SELECT COUNT(*) AS n FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
|
|
116
|
+
WHERE m.repo_id = ? AND p.status = 'enriched'`
|
|
117
|
+
).get(repoId);
|
|
118
|
+
return Number(hit?.n ?? 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
101
121
|
function legacyPages(repoId) {
|
|
102
122
|
const hit = db.prepare(
|
|
103
123
|
`SELECT COUNT(*) AS n FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
|
|
@@ -368,6 +388,152 @@ export function createChoroQueries({ store }) {
|
|
|
368
388
|
};
|
|
369
389
|
}
|
|
370
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Claim 粒度的失效清单(借 openwiki 的 Grounded Claims):把「仓库代码变过就整仓过期」
|
|
393
|
+
* 降成「这 2 页里的 5 条结论被改动碰过」。
|
|
394
|
+
*
|
|
395
|
+
* 为什么放读侧而不写成后台任务:判据是纯机械的(git diff 的 hunk 区间 × claims 的
|
|
396
|
+
* 三列证据),不需要模型,一次 diff 几十毫秒——面板打开时现算,永远比任何落库的
|
|
397
|
+
* 缓存新鲜。写路径一条不加,红线不动。
|
|
398
|
+
*
|
|
399
|
+
* **比对起点是每页自己的 verified_baseline,不是全仓 git_baseline。** claims 的行号
|
|
400
|
+
* 是那页被富化的那一刻记下的;全仓基线是 T0 的时点。若 T1 晚于 T0(常态),用全仓
|
|
401
|
+
* 基线起算就会把「T1 之前就发生、claim 早已算进去」的改动也算进 diff,误报 touched。
|
|
402
|
+
* 所以按 verified_baseline 分组、每组各跑一次 git diff(同一起点只跑一次)。
|
|
403
|
+
*
|
|
404
|
+
* 五种「说不清」要分开说,别混成一个空清单:
|
|
405
|
+
* - repo 不存在 → null(调用方 404)
|
|
406
|
+
* - git 读不到 HEAD → 全仓 anchorStale + reason "git-unreadable"
|
|
407
|
+
* - 某页基线认不出(浅克隆/force-push)→ 该页 anchorStale,**其余页照常判**
|
|
408
|
+
* - 某页没记过验证基线(老数据/拿不到 HEAD)→ 该页 baselineUnknown,一条不判
|
|
409
|
+
* - 起点 == HEAD → 那组真没变,clean
|
|
410
|
+
*/
|
|
411
|
+
function claimDrift(repoId) {
|
|
412
|
+
const repo = db.prepare("SELECT id, root_path, name, git_baseline FROM choro_repos WHERE id = ?").get(repoId);
|
|
413
|
+
if (!repo) return null;
|
|
414
|
+
const rootPath = String(repo.root_path ?? "");
|
|
415
|
+
const rows = db.prepare(
|
|
416
|
+
`SELECT c.id, c.statement, c.kind, c.source_path, c.start_line, c.end_line,
|
|
417
|
+
m.slug AS page_slug, m.title AS page_title, p.rel_path
|
|
418
|
+
FROM choro_claims c
|
|
419
|
+
JOIN choro_pages p ON c.page_id = p.id
|
|
420
|
+
JOIN choro_modules m ON p.module_id = m.id
|
|
421
|
+
WHERE m.repo_id = ? ORDER BY m.slug, c.id`
|
|
422
|
+
).all(repoId);
|
|
423
|
+
// 页面清单单算:hasT1 只能读盘(标记写在正文里,库里没有这一列);验证基线在库里。
|
|
424
|
+
const pageRows = db.prepare(
|
|
425
|
+
`SELECT m.slug, m.title, p.rel_path, p.verified_baseline, p.verified_at
|
|
426
|
+
FROM choro_pages p
|
|
427
|
+
JOIN choro_modules m ON p.module_id = m.id WHERE m.repo_id = ? ORDER BY m.slug`
|
|
428
|
+
).all(repoId);
|
|
429
|
+
const head = rootPath === "" ? null : gitHead(rootPath);
|
|
430
|
+
const baseline = String(repo.git_baseline ?? "").trim();
|
|
431
|
+
// 每组验证基线只跑一次 diff:富化往往成批完成,同一基线的页占多数。
|
|
432
|
+
const diffCache = new Map();
|
|
433
|
+
const diffFor = (verified) => {
|
|
434
|
+
if (head === null) return null;
|
|
435
|
+
const key = String(verified ?? "").trim();
|
|
436
|
+
if (key === "") return null; // 没记过起点 → 判不了,别拿别的页的起点凑
|
|
437
|
+
if (diffCache.has(key)) return diffCache.get(key);
|
|
438
|
+
let value = null;
|
|
439
|
+
if (key === head) value = { ok: true, files: new Map(), deleted: [] };
|
|
440
|
+
else {
|
|
441
|
+
const diff = diffSince(rootPath, key, head);
|
|
442
|
+
value = diff.ok ? diff : { ok: false, reason: diff.reason };
|
|
443
|
+
}
|
|
444
|
+
diffCache.set(key, value);
|
|
445
|
+
return value;
|
|
446
|
+
};
|
|
447
|
+
const pages = pageRows.map((p) => {
|
|
448
|
+
let hasT1 = false;
|
|
449
|
+
try {
|
|
450
|
+
hasT1 = readFileSync(join(rootPath, p.rel_path), "utf8").includes(T1_SECTION_START);
|
|
451
|
+
} catch { hasT1 = false; }
|
|
452
|
+
const verifiedBaseline = String(p.verified_baseline ?? "").trim() || null;
|
|
453
|
+
const verifiedAt = p.verified_at ?? null;
|
|
454
|
+
// 老数据(本功能之前富化的页)只有全仓基线可依。退到它是**保守**方向:它早于
|
|
455
|
+
// 那页被富化的时刻,diff 会把「T1 之前就发生」的改动也算进来,只会多报不会漏报。
|
|
456
|
+
// 记 baselineApprox 是为了面板能说清「这是退而求其次的起点」,不是当真的验证点。
|
|
457
|
+
//
|
|
458
|
+
// 但「验证过、却没记下基线」不是老数据,是**验证没留下凭据**(T1 那次富化写了
|
|
459
|
+
// verified_at 却没写 verified_baseline,比如中途失败)。这种情况退到全仓基线
|
|
460
|
+
// 就会把「富化之后才发生」的改动当成「富化之前就有的」而放过——方向是漏报,
|
|
461
|
+
// 与上面那条恰好相反。所以判成 baselineUnknown:不知道起点,就不装作知道。
|
|
462
|
+
const verifyWithoutBaseline = verifiedBaseline === null && verifiedAt !== null;
|
|
463
|
+
const effective = verifyWithoutBaseline ? null : (verifiedBaseline ?? (baseline || null));
|
|
464
|
+
const computed = diffFor(effective);
|
|
465
|
+
return {
|
|
466
|
+
slug: p.slug,
|
|
467
|
+
title: p.title,
|
|
468
|
+
hasT1,
|
|
469
|
+
verifiedBaseline,
|
|
470
|
+
verifiedAt,
|
|
471
|
+
diffBaseline: effective,
|
|
472
|
+
baselineApprox: !verifyWithoutBaseline && verifiedBaseline === null && effective !== null,
|
|
473
|
+
baselineUnknown: effective === null,
|
|
474
|
+
anchorStale: computed !== null && computed.ok === false,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
const claims = rows.map((r) => ({
|
|
478
|
+
id: r.id,
|
|
479
|
+
statement: r.statement,
|
|
480
|
+
kind: r.kind,
|
|
481
|
+
sourcePath: r.source_path,
|
|
482
|
+
startLine: r.start_line,
|
|
483
|
+
endLine: r.end_line,
|
|
484
|
+
pageSlug: r.page_slug,
|
|
485
|
+
pageTitle: r.page_title,
|
|
486
|
+
}));
|
|
487
|
+
const base = {
|
|
488
|
+
repo: { id: repo.id, name: repo.name, rootPath, gitBaseline: baseline || null },
|
|
489
|
+
head: head ?? null,
|
|
490
|
+
baseline: baseline || null,
|
|
491
|
+
};
|
|
492
|
+
if (head === null) {
|
|
493
|
+
return { ...base, clean: false, anchorStale: true, reason: "git-unreadable", ...summarizeClaims(claims, null, { pages }) };
|
|
494
|
+
}
|
|
495
|
+
// 仓库级 anchorStale 保留原来的语义:**骨架**(T0 页的文件清单、模块划分)记下的
|
|
496
|
+
// 那次基线还认不认得出。它和逐页 claims 的起点是两件事,面板上也是两句话——
|
|
497
|
+
// 「骨架要重建」不等于「结论都过时」。两条都算,谁也不掩盖谁。
|
|
498
|
+
let repoStale = false;
|
|
499
|
+
let repoStaleReason = null;
|
|
500
|
+
if (baseline !== "" && baseline !== head) {
|
|
501
|
+
const repoDiff = diffSince(rootPath, baseline, head);
|
|
502
|
+
if (!repoDiff.ok) { repoStale = true; repoStaleReason = repoDiff.reason; }
|
|
503
|
+
}
|
|
504
|
+
const diffByPage = (slug) => {
|
|
505
|
+
const page = pages.find((p) => p.slug === slug);
|
|
506
|
+
if (!page) return null;
|
|
507
|
+
const computed = diffFor(page.diffBaseline);
|
|
508
|
+
return computed !== null && computed.ok ? { files: computed.files, deleted: computed.deleted } : null;
|
|
509
|
+
};
|
|
510
|
+
const summary = summarizeClaims(claims, null, { pages, diffByPage });
|
|
511
|
+
// clean 的判据:每条结论都真判过、且一条都没被碰到。
|
|
512
|
+
// 「有结论但起点认不出 / 没记过」不算 clean——那是不知道,不是没事(#607 的病根);
|
|
513
|
+
// 一条结论都没有时也没有「没事」可说(那是「还没富化」,不是「全部干净」)。
|
|
514
|
+
// 仓库基线 == HEAD → 整仓一条提交都没发生,任何 claims 都不可能被碰过。这一条既短路
|
|
515
|
+
// 「逐页基线没记」的老数据(缺列不是判不了),也覆盖「刚跑完 T0、还没有 conclusion」
|
|
516
|
+
// 的情形:没变就是没变,不必等富化完才敢说。
|
|
517
|
+
// 不是这个情形时才逐页看:有哪页判不了就不说 clean,一条结论都没有也不说 clean
|
|
518
|
+
// (那是「还没富化」,不是「全部干净」——面板上这两句不一样)。
|
|
519
|
+
const repoAtHead = baseline !== "" && baseline === head;
|
|
520
|
+
const undecidable = summary.totals.baselineUnknownPages + summary.totals.anchorStalePages > 0;
|
|
521
|
+
// inconsistentPages 不参与 repoAtHead 的短路:它说的不是「起点认不出」,而是
|
|
522
|
+
// **页面有语义正文、库里却一条 claims 都没有**(上次 T1 没写完的残局)。这种页的
|
|
523
|
+
// 证据根本无法核对,与仓库有没有新提交无关;只要还有这种页,就不能说「全部干净」,
|
|
524
|
+
// 否则面板会把一次没落库的富化说成没事。
|
|
525
|
+
const clean = summary.totals.atRiskClaims === 0
|
|
526
|
+
&& summary.totals.inconsistentPages === 0
|
|
527
|
+
&& (repoAtHead || (summary.totals.claims > 0 && !undecidable && !repoStale));
|
|
528
|
+
return {
|
|
529
|
+
...base,
|
|
530
|
+
clean,
|
|
531
|
+
anchorStale: repoStale,
|
|
532
|
+
reason: repoStale ? repoStaleReason : null,
|
|
533
|
+
...summary,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
371
537
|
// --- chorography 向量(收尾档 P37–P39)---------------------------------
|
|
372
538
|
// 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入);
|
|
373
539
|
// CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维:
|
|
@@ -468,7 +634,7 @@ export function createChoroQueries({ store }) {
|
|
|
468
634
|
|
|
469
635
|
return {
|
|
470
636
|
listRepos, listRepoFacts, repoFacts, repoFactsById, repoFromRow, listRepoRow, repoByRoot,
|
|
471
|
-
resolveRepo, moduleMap, readPage, search, repoByCwd, outline,
|
|
637
|
+
resolveRepo, moduleMap, readPage, search, repoByCwd, outline, claimDrift,
|
|
472
638
|
embedPageVectors, searchSemantic, embedQueryText, semanticTopN,
|
|
473
639
|
};
|
|
474
640
|
}
|
package/lib/wiki/render.js
CHANGED
|
@@ -23,6 +23,80 @@ export const HUMAN_END = "<!-- CHORO:HUMAN:END -->";
|
|
|
23
23
|
export const T1_SECTION_START = "<!-- CHORO:T1:START -->";
|
|
24
24
|
export const T1_SECTION_END = "<!-- CHORO:T1:END -->";
|
|
25
25
|
|
|
26
|
+
// 验证溯源的两个 frontmatter 键(issue #615 之后的 ①:语义段带上「对着哪次提交验证的」)。
|
|
27
|
+
// 只有 T1 写它们,T0 渲染时一个字节都不产出——骨架页没有语义段,也就没有「哪次验证的」
|
|
28
|
+
// 这回事;反过来说,T0 重建整页时这两个键随 T1 正文一起消失,与「T0 丢弃语义段」是
|
|
29
|
+
// 同一件事,不新增语义。放在这里而不是 t1.js:frontmatter 的形状属于页面格式。
|
|
30
|
+
export const FM_VERIFIED_BASELINE = "verifiedBaseline";
|
|
31
|
+
export const FM_VERIFIED_AT = "verifiedAt";
|
|
32
|
+
|
|
33
|
+
/** frontmatter 边界:首行 --- 与下一个 --- 的行号;不存在返回 null。 */
|
|
34
|
+
function frontmatterBounds(lines) {
|
|
35
|
+
if (String(lines[0] ?? "").trim() !== "---") return null;
|
|
36
|
+
for (let i = 1; i < lines.length; i++) {
|
|
37
|
+
if (String(lines[i]).trim() === "---") return { open: 0, close: i };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 取 frontmatter 里某个顶层键的字符串值。
|
|
44
|
+
*
|
|
45
|
+
* 只认顶层键(`key: value`),缩进的列表项(` - x`)不会误命中;值两侧的引号剥掉
|
|
46
|
+
* (yamlScalar 会给含特殊字符的值加引号,读回来要还原成原文)。
|
|
47
|
+
* 没有 frontmatter、键不存在、值为空 → 一律 null:调用方据此说「没记下」,
|
|
48
|
+
* 而不是把空串当成一个合法的提交号。
|
|
49
|
+
*/
|
|
50
|
+
export function readFrontmatterKey(pageText, key) {
|
|
51
|
+
const lines = String(pageText ?? "").split("\n");
|
|
52
|
+
const bounds = frontmatterBounds(lines);
|
|
53
|
+
if (!bounds) return null;
|
|
54
|
+
const re = new RegExp(`^${escapeRegExp(key)}\\s*:\\s*(.*)$`);
|
|
55
|
+
for (let i = bounds.open + 1; i < bounds.close; i++) {
|
|
56
|
+
const m = re.exec(lines[i]);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
let v = m[1].trim();
|
|
59
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
60
|
+
try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
|
|
61
|
+
}
|
|
62
|
+
return v === "" ? null : v;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 就地设置 frontmatter 顶层键:有则覆盖、无则追加在收尾 --- 之前(按传入顺序)。
|
|
69
|
+
*
|
|
70
|
+
* value 为 null / undefined / 空串 = **删掉**这个键,不留 `key:` 空行——「这一页是哪次
|
|
71
|
+
* 提交验证的,T1 没记下来」本身要能在文件里查得出来,而不是显示成一片空白。
|
|
72
|
+
* 没有 frontmatter 的正文原样返回:绝不给人写的坏文件凭空造一个块。
|
|
73
|
+
*
|
|
74
|
+
* @param {string} pageText
|
|
75
|
+
* @param {Array<[string, string|null]>} entries
|
|
76
|
+
*/
|
|
77
|
+
export function setFrontmatterKeys(pageText, entries) {
|
|
78
|
+
const lines = String(pageText ?? "").split("\n");
|
|
79
|
+
const bounds = frontmatterBounds(lines);
|
|
80
|
+
if (!bounds) return String(pageText ?? "");
|
|
81
|
+
const pending = new Map(entries.map(([k, v]) => [k, v === undefined || v === null || String(v).trim() === "" ? null : String(v)]));
|
|
82
|
+
const out = lines.slice(0, bounds.open + 1);
|
|
83
|
+
for (let i = bounds.open + 1; i < bounds.close; i++) {
|
|
84
|
+
const m = /^([A-Za-z_][\w-]*)\s*:/.exec(lines[i]);
|
|
85
|
+
if (m && pending.has(m[1])) {
|
|
86
|
+
const v = pending.get(m[1]);
|
|
87
|
+
pending.delete(m[1]);
|
|
88
|
+
if (v !== null) out.push(`${m[1]}: ${v}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out.push(lines[i]);
|
|
92
|
+
}
|
|
93
|
+
for (const [k, v] of pending) {
|
|
94
|
+
if (v !== null) out.push(`${k}: ${v}`);
|
|
95
|
+
}
|
|
96
|
+
out.push(...lines.slice(bounds.close));
|
|
97
|
+
return out.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
26
100
|
/** 抽出全部保留区。返回 { blocks: string[](含标记本身,原样), count }。 */
|
|
27
101
|
export function extractHumanBlocks(pageText) {
|
|
28
102
|
if (typeof pageText !== "string") return { blocks: [], count: 0 };
|
package/lib/wiki/t0.js
CHANGED
|
@@ -39,9 +39,10 @@ export class ChoroRefusal extends Error {
|
|
|
39
39
|
* @param {string} opts.repoRoot 仓库路径(内部 realpath)
|
|
40
40
|
* @param {object|null} opts.store createStore 产物(apply 时必传;dry-run 可为 null)
|
|
41
41
|
* @param {boolean} [opts.apply=false] false = dry-run(零写入)
|
|
42
|
+
* @param {boolean} [opts.guardT1Loss=false] 会丢掉已有语义段时整轮拒绝(自动刷新用)
|
|
42
43
|
* @param {() => string} [opts.now] ISO 时刻(测试注入假时钟)
|
|
43
44
|
*/
|
|
44
|
-
export function runChoroT0({ repoRoot, store = null, apply = false, now = () => new Date().toISOString() }) {
|
|
45
|
+
export function runChoroT0({ repoRoot, store = null, apply = false, guardT1Loss = false, now = () => new Date().toISOString() }) {
|
|
45
46
|
let rootPath;
|
|
46
47
|
try {
|
|
47
48
|
rootPath = realpathSync(resolve(repoRoot));
|
|
@@ -177,6 +178,36 @@ export function runChoroT0({ repoRoot, store = null, apply = false, now = () =>
|
|
|
177
178
|
+ "要留住语义段就别在这里重跑 T0;需要时再用 --t1 重新富化。"
|
|
178
179
|
);
|
|
179
180
|
}
|
|
181
|
+
if (t1Loss.unreadable.length > 0) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`${t1Loss.unreadable.length} 页读不出来`
|
|
184
|
+
+ `(${t1Loss.unreadable.slice(0, 3).map((u) => u.relPath).join("、")}${t1Loss.unreadable.length > 3 ? " 等" : ""}),`
|
|
185
|
+
+ "无法确认其中是否有 T1 语义段;自动刷新会因此拒绝,手动重建会按骨架重写这些页。"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
// guardT1Loss(自动刷新专用):读路径上「顺手」重跑 T0 会把用户花模型调用换来的
|
|
189
|
+
// 语义段和 claims 一起清掉——那不是顺手,那是在用户只点开一页的时候替他做了一次
|
|
190
|
+
// 破坏性写盘。手动重建不受此限:用户点了按钮,回执里的 warnings 会如实列出丢了什么。
|
|
191
|
+
//
|
|
192
|
+
// 拒绝而不是「跳过有语义段的那几页」:T0 的整仓 replaceChoroGraph 是一次事务,
|
|
193
|
+
// 只保几页会留下「页面有 T1 正文、库里 claims 已被清空」的半截状态,比整体不写更糟。
|
|
194
|
+
//
|
|
195
|
+
// 读不出来的页同样要拒绝:清点靠读盘,读失败就先不知道它有没有语义段。放行等于
|
|
196
|
+
// 赌它没有,赌输就是把它那份语义段和 claims 一起抹掉;拒绝只是这次不刷新。
|
|
197
|
+
if (guardT1Loss && (t1Loss.pages.length > 0 || t1Loss.unreadable.length > 0)) {
|
|
198
|
+
const names = t1Loss.pages.slice(0, 3).join("、");
|
|
199
|
+
const blockedBy = t1Loss.unreadable.length > 0
|
|
200
|
+
? `另有 ${t1Loss.unreadable.length} 页读不出来(${t1Loss.unreadable.slice(0, 3).map((u) => u.relPath).join("、")}`
|
|
201
|
+
+ `${t1Loss.unreadable.length > 3 ? " 等" : ""}),无法确认它们有没有语义段。`
|
|
202
|
+
: "";
|
|
203
|
+
throw new ChoroRefusal(
|
|
204
|
+
"would-drop-t1",
|
|
205
|
+
`这个仓库有 ${t1Loss.pages.length} 页已经富化过(${names}${t1Loss.pages.length > 3 ? " 等" : ""})。`
|
|
206
|
+
+ blockedBy
|
|
207
|
+
+ "自动刷新会退回骨架、清空 claims,所以这次没有动:"
|
|
208
|
+
+ "要保留语义段就先别刷新,确实要重建骨架请在面板里手动点「重建骨架」(回执会列出丢了哪几页)。"
|
|
209
|
+
);
|
|
210
|
+
}
|
|
180
211
|
|
|
181
212
|
if (!apply) {
|
|
182
213
|
return {
|
|
@@ -196,7 +227,7 @@ export function runChoroT0({ repoRoot, store = null, apply = false, now = () =>
|
|
|
196
227
|
skippedFiles: graph.skipped,
|
|
197
228
|
unresolvedCount: unresolvedTotal,
|
|
198
229
|
humanBlocksSeen: countExistingHumanBlocks(rootPath, pageTargets),
|
|
199
|
-
t1Loss: { pages: t1Loss.pages, orphanEvidence: t1Loss.orphanEvidence },
|
|
230
|
+
t1Loss: { pages: t1Loss.pages, unreadable: t1Loss.unreadable, orphanEvidence: t1Loss.orphanEvidence },
|
|
200
231
|
wouldWrite: [...pageTargets.map((p) => ({ path: p.relPath, bytes: Buffer.byteLength(p.content, "utf8") })),
|
|
201
232
|
{ path: indexRelPath, bytes: Buffer.byteLength(indexContent, "utf8") },
|
|
202
233
|
{ path: `${CHORO_DIR}/state.json`, bytes: 0 }],
|
|
@@ -282,7 +313,7 @@ export function runChoroT0({ repoRoot, store = null, apply = false, now = () =>
|
|
|
282
313
|
unresolvedCount: unresolvedTotal,
|
|
283
314
|
humanBlocksPreserved,
|
|
284
315
|
bytes,
|
|
285
|
-
t1Loss: { pages: t1Loss.pages, orphanEvidence: t1Loss.orphanEvidence },
|
|
316
|
+
t1Loss: { pages: t1Loss.pages, unreadable: t1Loss.unreadable, orphanEvidence: t1Loss.orphanEvidence },
|
|
286
317
|
warnings,
|
|
287
318
|
moduleSummary: modules.map((m) => ({
|
|
288
319
|
slug: m.slug, title: m.title, fileCount: m.fileCount,
|
|
@@ -421,13 +452,19 @@ function existingHumanBlockCount(absPath) {
|
|
|
421
452
|
// 引用者。两者都是「会失去什么」的清点,不构成阻止:丢弃是设计意图,静默才是 bug。
|
|
422
453
|
function collectT1Loss(rootPath, pageTargets) {
|
|
423
454
|
const pages = [];
|
|
455
|
+
const unreadable = [];
|
|
424
456
|
for (const target of pageTargets) {
|
|
425
457
|
const abs = join(rootPath, target.relPath);
|
|
426
458
|
if (!existsSync(abs)) continue;
|
|
427
459
|
try {
|
|
428
460
|
if (readFileSync(abs, "utf8").includes(T1_SECTION_START)) pages.push(target.relPath);
|
|
429
|
-
} catch {
|
|
430
|
-
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// **读不了不等于没有**:权限、占用、编码错误都会走到这里,而「有没有 T1 语义段」
|
|
463
|
+
// 恰恰要靠读那一眼才知道。吞成「没有」的直接后果是 guard 放行,然后一整仓
|
|
464
|
+
// replaceChoroGraph 把读不到的页连同它可能有的语义段一起退回骨架、清空 claims。
|
|
465
|
+
// 清点本身不该让 T0 失败(下面 warnings 只如实记一笔),但**守卫**必须把它当成
|
|
466
|
+
// 「有会丢的东西、但我数不清」而拒绝——见 guardT1Loss 分支。
|
|
467
|
+
unreadable.push({ relPath: target.relPath, reason: String(err?.code ?? err?.message ?? err) });
|
|
431
468
|
}
|
|
432
469
|
}
|
|
433
470
|
let orphanEvidence = 0;
|
|
@@ -437,7 +474,7 @@ function collectT1Loss(rootPath, pageTargets) {
|
|
|
437
474
|
} catch {
|
|
438
475
|
orphanEvidence = 0;
|
|
439
476
|
}
|
|
440
|
-
return { pages, orphanEvidence };
|
|
477
|
+
return { pages, unreadable, orphanEvidence };
|
|
441
478
|
}
|
|
442
479
|
|
|
443
480
|
function countExistingHumanBlocks(rootPath, pageTargets) {
|