opencode-wiki-historian 0.4.0 → 0.5.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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * src/surface.ts — human-interface & content-hygiene detectors (V6.1).
3
+ *
4
+ * maintain.ts owns per-page G5/G6 freshness; this module owns the WIKI-WIDE
5
+ * structural health of the human surface (HANDOFF issues #1-#6): navigation
6
+ * hygiene, map-vs-live coverage, redirect-stub reachability, broken / stacked /
7
+ * index-less links, orphans, twin divergence, zh-first language, unfinished
8
+ * skeletons and claim ledgers that beg re-verification.
9
+ *
10
+ * Pure functions over rows + live inventory + an injected readBody; the light
11
+ * tier costs nothing beyond the map baseline, the deep tier reads every body
12
+ * once (caller pools the reads).
13
+ */
14
+ import { isInternalPath } from './tools/shared.js';
15
+ import { lintBody } from './lint.js';
16
+ import { classifyGenre } from './templates/genres.js';
17
+ export const SURFACE_SCHEMA = 'historian.surface.v1';
18
+ const MACHINE_SEG_RE = /^_/;
19
+ // nav targets carry a leading slash and may pre-pend the locale segment
20
+ // (/zh/_meta/x) — machine check runs on the first real path segment.
21
+ const MACHINE_TARGET_RE = /^\/(?:(?:en|zh)\/)?_[^/]+/;
22
+ const ROOT_EXEMPT = new Set(['home', 'wiki-index']);
23
+ const visible = (r) => r.isPublished !== false && r.isPrivate !== true;
24
+ const rowKey = (path, locale) => `${locale}\u0000${path}`;
25
+ function isFrontPath(path) {
26
+ return !isInternalPath(path) && !path.startsWith('_sandbox/') && !path.startsWith('_data/');
27
+ }
28
+ // --- light tier ---------------------------------------------------------------
29
+ function buildNav(rows, live, nav) {
30
+ const paths = new Set();
31
+ for (const r of rows)
32
+ paths.add(r.path);
33
+ for (const r of live ?? [])
34
+ paths.add(r.path);
35
+ const machinePaths = [...new Set([...paths].map((p) => p.split('/')[0]))]
36
+ .filter((s) => MACHINE_SEG_RE.test(s))
37
+ .sort();
38
+ const mode = nav?.mode ?? null;
39
+ const machineLinks = [];
40
+ for (const t of nav?.trees ?? []) {
41
+ for (const it of t.items) {
42
+ if (MACHINE_TARGET_RE.test(it.target)) {
43
+ machineLinks.push({ locale: t.locale, label: it.label, target: it.target });
44
+ }
45
+ }
46
+ }
47
+ const perDir = new Map();
48
+ for (const p of paths) {
49
+ const seg = p.split('/');
50
+ if (seg.length < 2 || MACHINE_SEG_RE.test(seg[0]))
51
+ continue;
52
+ perDir.set(seg[0], (perDir.get(seg[0]) ?? 0) + 1);
53
+ }
54
+ const sectionLandingMissing = [...perDir.entries()]
55
+ .filter(([dir, n]) => n >= 2 && !paths.has(dir))
56
+ .map(([dir, pagePaths]) => ({ dir, pagePaths }))
57
+ .sort((a, b) => b.pagePaths - a.pagePaths || a.dir.localeCompare(b.dir));
58
+ return {
59
+ available: nav != null,
60
+ mode,
61
+ filesystemExposed: mode === 'DYNAMIC' || mode === 'MIXED',
62
+ machineLinks,
63
+ machinePaths,
64
+ sectionLandingMissing,
65
+ };
66
+ }
67
+ function buildCoverage(rows, live) {
68
+ if (live === undefined)
69
+ return null;
70
+ const mapKeys = new Set(rows.map((r) => rowKey(r.path, r.locale)));
71
+ const liveVisible = live.filter(visible);
72
+ const liveKeys = new Set(liveVisible.map((r) => rowKey(r.path, r.locale)));
73
+ const missingFromMap = liveVisible
74
+ .filter((r) => !mapKeys.has(rowKey(r.path, r.locale)))
75
+ .map((r) => ({ path: r.path, locale: r.locale }))
76
+ .sort((a, b) => a.path.localeCompare(b.path) || a.locale.localeCompare(b.locale));
77
+ let removedFromLive = 0;
78
+ for (const k of mapKeys)
79
+ if (!liveKeys.has(k))
80
+ removedFromLive += 1;
81
+ return { liveRows: liveVisible.length, mapRows: rows.length, missingFromMap, removedFromLive };
82
+ }
83
+ const STACK_MD_RE = /\[([^\]]+)\]\((?:https?:\/\/[^\s)]+|\/[^\s)]*)\)/g;
84
+ function anchorTexts(body) {
85
+ const out = [];
86
+ STACK_MD_RE.lastIndex = 0;
87
+ let m;
88
+ while ((m = STACK_MD_RE.exec(body)) !== null)
89
+ out.push({ raw: m[0], text: m[1].trim() });
90
+ return out;
91
+ }
92
+ function resolveTargetPath(baseUrl, rawHref) {
93
+ let t = rawHref.trim();
94
+ if (t === '' || t.startsWith('#') || /^(mailto:|javascript:)/i.test(t))
95
+ return null;
96
+ const host = baseUrl.replace(/\/+$/, '');
97
+ if (/^https?:\/\//i.test(t)) {
98
+ if (!t.toLowerCase().startsWith(host.toLowerCase()))
99
+ return null;
100
+ t = t.slice(host.length);
101
+ }
102
+ if (/\.(png|jpe?g|gif|svg|webp|pdf|zip|css|js)\b/i.test(t))
103
+ return null;
104
+ t = t.split('#')[0].split('?')[0];
105
+ t = t.replace(/^\/+/, '');
106
+ if (t === '')
107
+ return null;
108
+ if (t.startsWith('zh/'))
109
+ return { path: t.slice(3), locale: 'zh' };
110
+ if (t.startsWith('en/'))
111
+ return { path: t.slice(3), locale: 'en' };
112
+ return { path: t, locale: 'en' };
113
+ }
114
+ async function scanBodies(input) {
115
+ if (input.readBody === undefined)
116
+ return [];
117
+ const targets = input.rows.filter((r) => !isInternalPath(r.path));
118
+ const results = [];
119
+ const queue = [...targets];
120
+ const worker = async () => {
121
+ for (;;) {
122
+ const row = queue.shift();
123
+ if (row === undefined)
124
+ return;
125
+ const body = (await input.readBody?.(row.path, row.locale)) ?? '';
126
+ results.push({ row, lint: body === '' ? null : lintBody(body, { locale: row.locale, baseUrl: input.baseUrl, title: row.title }), body });
127
+ }
128
+ };
129
+ await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
130
+ return results;
131
+ }
132
+ function buildDeep(input, scans) {
133
+ if (scans.length === 0)
134
+ return null;
135
+ const ok = scans.filter((s) => s.lint !== null);
136
+ const unfinished = ok
137
+ .filter((s) => !s.lint.isRedirectStub && isFrontPath(s.row.path))
138
+ .filter((s) => s.lint.todoMarkers > 0 || s.lint.emptySections.length > 0 || s.lint.introEmpty)
139
+ .map((s) => ({
140
+ path: s.row.path,
141
+ locale: s.row.locale,
142
+ title: s.row.title,
143
+ todo: s.lint.todoMarkers,
144
+ emptySections: s.lint.emptySections,
145
+ introEmpty: s.lint.introEmpty,
146
+ active: s.lint.state === 'active',
147
+ h1Mismatch: s.lint.h1 !== null && s.lint.h1.trim() !== s.row.title.trim(),
148
+ }))
149
+ .sort((a, b) => Number(b.active) - Number(a.active) || b.todo + b.emptySections.length - (a.todo + a.emptySections.length));
150
+ const pathSet = new Set();
151
+ for (const r of input.rows)
152
+ pathSet.add(r.path);
153
+ for (const r of input.liveInventory ?? [])
154
+ pathSet.add(r.path);
155
+ const stubPaths = new Map();
156
+ const stubs = [];
157
+ for (const s of ok.filter((x) => x.lint.isRedirectStub)) {
158
+ const target = s.lint.redirectTarget === null ? null : resolveTargetPath(input.baseUrl, s.lint.redirectTarget)?.path ?? null;
159
+ stubs.push({ path: s.row.path, locale: s.row.locale, target: s.lint.redirectTarget, clickable: s.lint.stubHasLink, targetLive: target !== null && pathSet.has(target) });
160
+ if (!stubPaths.has(s.row.path))
161
+ stubPaths.set(s.row.path, new Set());
162
+ stubPaths.get(s.row.path).add(s.row.locale);
163
+ }
164
+ stubs.sort((a, b) => Number(a.clickable) - Number(b.clickable) || a.path.localeCompare(b.path));
165
+ const broken = [];
166
+ const toStubs = [];
167
+ const inbound = new Map();
168
+ const indexTargets = new Set();
169
+ const sameTargetStacks = [];
170
+ for (const s of ok) {
171
+ const isIndex = s.row.path === 'wiki-index';
172
+ const byTarget = new Map();
173
+ for (const a of anchorTexts(s.body)) {
174
+ const t = resolveTargetPath(input.baseUrl, a.raw.slice(a.raw.indexOf('](') + 2, -1));
175
+ if (t === null)
176
+ continue;
177
+ if (!byTarget.has(t.path))
178
+ byTarget.set(t.path, new Set());
179
+ byTarget.get(t.path).add(a.text);
180
+ }
181
+ for (const link of s.lint.links) {
182
+ if (!isFrontPath(link.path) || ROOT_EXEMPT.has(link.path))
183
+ continue;
184
+ if (!link.path.startsWith('_sandbox/'))
185
+ inbound.set(link.path, (inbound.get(link.path) ?? 0) + 1);
186
+ if (isIndex)
187
+ indexTargets.add(link.path);
188
+ if (link.path === s.row.path)
189
+ continue;
190
+ if (!pathSet.has(link.path)) {
191
+ if (!broken.some((b) => b.from === s.row.path && b.locale === s.row.locale && b.target === link.path)) {
192
+ broken.push({ from: s.row.path, locale: s.row.locale, target: link.path });
193
+ }
194
+ }
195
+ else if (stubPaths.has(link.path) && !broken.some((b) => b.target === link.path)) {
196
+ toStubs.push({ from: s.row.path, locale: s.row.locale, target: link.path });
197
+ }
198
+ }
199
+ for (const [target, texts] of byTarget) {
200
+ if (texts.size >= 3 && pathSet.has(target))
201
+ sameTargetStacks.push({ from: s.row.path, locale: s.row.locale, target, texts: texts.size });
202
+ }
203
+ }
204
+ const liveByPathLocale = new Map();
205
+ for (const s of ok)
206
+ liveByPathLocale.set(rowKey(s.row.path, s.row.locale), s);
207
+ const roleDivergence = [];
208
+ const twinParity = [];
209
+ const pathsAll = new Set();
210
+ for (const s of ok)
211
+ pathsAll.add(s.row.path);
212
+ for (const p of [...pathsAll].sort()) {
213
+ const en = liveByPathLocale.get(rowKey(p, 'en'));
214
+ const zh = liveByPathLocale.get(rowKey(p, 'zh'));
215
+ const enStub = stubPaths.get(p)?.has('en') ?? false;
216
+ const zhStub = stubPaths.get(p)?.has('zh') ?? false;
217
+ if (enStub !== zhStub && en !== undefined && zh !== undefined) {
218
+ roleDivergence.push({ path: p, stubIn: enStub ? 'en' : 'zh', liveIn: enStub ? 'zh' : 'en' });
219
+ }
220
+ if (en !== undefined && zh !== undefined && !enStub && !zhStub && isFrontPath(p)) {
221
+ const enLen = (en.body.match(/\S/g) ?? []).length;
222
+ const zhLen = (zh.body.match(/\S/g) ?? []).length;
223
+ if (enLen === 0 || zhLen === 0)
224
+ continue;
225
+ // Length must be script-weighted: a CJK glyph carries ~3x the information
226
+ // of a latin char, so raw lengths flagged faithful zh translations as
227
+ // "truncated" (0.47 ratio at 1.0 section parity in the live scan).
228
+ const eff = (n, cjkRatio) => n * (1 + 2 * cjkRatio);
229
+ // Structural, not literal: en/zh heading STRINGS are translations, so
230
+ // text-jaccard measured 0 on 85/98 live pairs (pure noise). What survives
231
+ // translation: level>=2 section count + Related Pages tail (SYN-9).
232
+ const h2 = (l) => (l?.headings ?? []).filter((h) => h.level >= 2).length;
233
+ const nEn = h2(en.lint);
234
+ const nZh = h2(zh.lint);
235
+ const sectionCountRatio = Math.max(nEn, nZh) === 0 ? 1 : Math.min(nEn, nZh) / Math.max(nEn, nZh);
236
+ const relatedMismatch = (en.lint?.hasRelatedPages ?? false) !== (zh.lint?.hasRelatedPages ?? false);
237
+ const lenRatio = Math.min(eff(enLen, en.lint?.cjkRatio ?? 0), eff(zhLen, zh.lint?.cjkRatio ?? 0))
238
+ / Math.max(eff(enLen, en.lint?.cjkRatio ?? 0), eff(zhLen, zh.lint?.cjkRatio ?? 0));
239
+ twinParity.push({
240
+ path: p,
241
+ lenRatio: Number(lenRatio.toFixed(2)),
242
+ sectionCountRatio: Number(sectionCountRatio.toFixed(2)),
243
+ relatedMismatch,
244
+ divergent: sectionCountRatio < 0.6 || lenRatio < 0.5 || relatedMismatch,
245
+ });
246
+ }
247
+ }
248
+ twinParity.sort((a, b) => Number(b.divergent) - Number(a.divergent) || a.lenRatio - b.lenRatio);
249
+ const zhEnglishDominant = ok
250
+ .filter((s) => s.row.locale === 'zh' && isFrontPath(s.row.path) && !s.lint.isRedirectStub)
251
+ .filter((s) => (s.body.match(/\S/g) ?? []).length > 400 && s.lint.cjkRatio < 0.06)
252
+ .map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
253
+ const ledgerClaims = [];
254
+ for (const s of ok) {
255
+ if (s.lint.isRedirectStub || !isFrontPath(s.row.path) || s.lint.hasStamp)
256
+ continue;
257
+ const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
258
+ if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
259
+ continue;
260
+ if (s.lint.claimTotal < 3)
261
+ continue;
262
+ ledgerClaims.push({ path: s.row.path, locale: s.row.locale, genre, ports: s.lint.claims.ports, paths: s.lint.claims.paths, commands: s.lint.claims.commands });
263
+ }
264
+ ledgerClaims.sort((a, b) => b.ports + b.paths + b.commands - (a.ports + a.paths + a.commands));
265
+ const orphanPages = ok
266
+ .filter((s) => isFrontPath(s.row.path) && !s.lint.isRedirectStub && !ROOT_EXEMPT.has(s.row.path))
267
+ .map((s) => s.row.path)
268
+ .filter((p, i, arr) => arr.indexOf(p) === i && (inbound.get(p) ?? 0) === 0)
269
+ .sort();
270
+ const indexMissing = [...new Set(ok.filter((s) => isFrontPath(s.row.path)).map((s) => s.row.path))]
271
+ .filter((p) => !ROOT_EXEMPT.has(p) && !(stubPaths.get(p)?.has('en') ?? false) && !indexTargets.has(p))
272
+ .sort();
273
+ return {
274
+ unfinished,
275
+ stubs,
276
+ links: { broken, toStubs, sameTargetStacks, orphanPages, indexMissing },
277
+ roleDivergence,
278
+ twinParity,
279
+ zhEnglishDominant,
280
+ ledgerClaims,
281
+ };
282
+ }
283
+ // --- entry --------------------------------------------------------------------
284
+ export async function buildSurfaceReport(input) {
285
+ const scans = input.deep === true ? await scanBodies(input) : [];
286
+ return {
287
+ schema: SURFACE_SCHEMA,
288
+ generatedAt: input.generatedAt,
289
+ deep: input.deep === true,
290
+ coverage: buildCoverage(input.rows, input.liveInventory),
291
+ nav: buildNav(input.rows, input.liveInventory, input.nav),
292
+ tagsEmpty: input.rows
293
+ .filter((r) => isFrontPath(r.path) && (r.tags?.length ?? 0) === 0)
294
+ .map((r) => ({ path: r.path, locale: r.locale }))
295
+ .sort((a, b) => a.path.localeCompare(b.path)),
296
+ deepReport: buildDeep(input, scans),
297
+ };
298
+ }
299
+ // --- render -------------------------------------------------------------------
300
+ const cap = (arr, n) => arr.slice(0, n);
301
+ export function renderSurfaceMarkdown(r) {
302
+ const L = [];
303
+ L.push(`# 界面健康 Surface Report (${r.deep ? 'deep' : 'light'})`, '');
304
+ L.push(`> ${r.generatedAt} · schema ${r.schema}`, '');
305
+ L.push('## 覆盖 Coverage(地图 vs 实时)', '');
306
+ if (r.coverage === null)
307
+ L.push('- (未提供 live inventory — 由 `maintain` 工具自动采集)');
308
+ else {
309
+ L.push(`- live ${r.coverage.liveRows} 行 / map ${r.coverage.mapRows} 行 · 地图外 missingFromMap ${r.coverage.missingFromMap.length} · 地图内已消失 removedFromLive ${r.coverage.removedFromLive}`);
310
+ for (const m of cap(r.coverage.missingFromMap, 40))
311
+ L.push(` - \`${m.locale}/${m.path}\``);
312
+ }
313
+ L.push('', '## 导航 Nav(真相 = 实时导航树,非页面树推断)', '');
314
+ if (!r.nav.available) {
315
+ L.push('- ⚠ 导航树不可读(nav.available=false)— 机器段暴露无法核验,请检查 token 的导航读取权限');
316
+ }
317
+ else {
318
+ L.push(`- mode: \`${r.nav.mode}\` · 文件系统暴露 filesystemExposed: ${r.nav.filesystemExposed ? '⚠ 是 — DYNAMIC/MIXED 会把页面树镜像回侧栏(Issue #1 复发)' : '否'}`);
319
+ if (r.nav.machineLinks.length > 0) {
320
+ L.push(`- ⚠ 导航树内机器段链接 machineLinks (${r.nav.machineLinks.length}):`);
321
+ for (const m of cap(r.nav.machineLinks, 20))
322
+ L.push(` - [${m.locale}] ${m.label} → \`${m.target}\``);
323
+ }
324
+ else {
325
+ L.push(`- 导航树内机器段链接: none ✓(页面树存档段 ${r.nav.machinePaths.map((s) => `\`${s}/\``).join(' ') || '—'} 属设计内,仅备查)`);
326
+ }
327
+ }
328
+ if (r.nav.sectionLandingMissing.length > 0) {
329
+ L.push(`- 落地页缺失 sectionLandingMissing(面包屑 404 / 空目录页):`);
330
+ for (const s of r.nav.sectionLandingMissing)
331
+ L.push(` - \`/${s.dir}\` — ${s.pagePaths} 页在此目录下`);
332
+ }
333
+ L.push('', `## 元数据卫生 Tags(前台空标签 ${r.tagsEmpty.length})`, '');
334
+ if (r.tagsEmpty.length === 0)
335
+ L.push('- none');
336
+ else {
337
+ const byPath = new Map();
338
+ for (const t of r.tagsEmpty)
339
+ byPath.set(t.path, [...(byPath.get(t.path) ?? []), t.locale]);
340
+ for (const [p, ls] of cap([...byPath.entries()], 40))
341
+ L.push(`- \`${p}\` (${ls.join(',')})`);
342
+ }
343
+ if (r.deepReport === null) {
344
+ L.push('', '## Deep —(light 扫描未含正文级检测;用 deep:true)', '');
345
+ L.push('', '```json', JSON.stringify(r, null, 2), '```');
346
+ return L.join('\n');
347
+ }
348
+ const d = r.deepReport;
349
+ L.push('', `## 未完成正文 Unfinished skeletons (${d.unfinished.length})`, '');
350
+ L.push('| Path | 语言 | TODO | 空节 | 导言空 | Active谎报 | H1≠标题 |', '| --- | --- | --- | --- | --- | --- | --- |');
351
+ for (const u of cap(d.unfinished, 50)) {
352
+ L.push(`| \`${u.path}\` | ${u.locale} | ${u.todo} | ${u.emptySections.length} | ${u.introEmpty ? '✓' : ''} | ${u.active ? '**✓**' : ''} | ${u.h1Mismatch ? '✓' : ''} |`);
353
+ }
354
+ L.push('', `## 重定向存根 Redirect stubs (${d.stubs.length})`, '');
355
+ const dead = d.stubs.filter((s) => !s.clickable || !s.targetLive);
356
+ L.push(`- 无出口或死目标 dead/no-exit: ${dead.length}${dead.length > 0 ? '' : ' ✓'}`);
357
+ for (const s of cap(dead, 30))
358
+ L.push(` - \`${s.locale}/${s.path}\` → ${s.target ?? '?'} ${s.clickable ? '' : '(正文无可点击链接)'}${s.targetLive ? '' : '(目标不存在)'}`);
359
+ L.push('', '## 链接 Links', '');
360
+ L.push(`- 断链 broken: ${d.links.broken.length}`);
361
+ for (const b of cap(d.links.broken, 30))
362
+ L.push(` - \`${b.locale}/${b.from}\` → \`${b.target}\``);
363
+ L.push(`- 指向存根 toStubs: ${d.links.toStubs.length}`);
364
+ for (const b of cap(d.links.toStubs, 20))
365
+ L.push(` - \`${b.locale}/${b.from}\` → 存根 \`${b.target}\``);
366
+ L.push(`- 同目标堆叠 sameTargetStacks(≥3 锚文本指同页): ${d.links.sameTargetStacks.length}`);
367
+ for (const b of cap(d.links.sameTargetStacks, 20))
368
+ L.push(` - \`${b.locale}/${b.from}\` × ${b.texts} → \`${b.target}\``);
369
+ L.push(`- 孤儿 orphanPages(全库无任何入链): ${d.links.orphanPages.length}`);
370
+ for (const p of cap(d.links.orphanPages, 30))
371
+ L.push(` - \`${p}\``);
372
+ L.push(`- 索引缺席 indexMissing(wiki-index 未收录): ${d.links.indexMissing.length}`);
373
+ for (const p of cap(d.links.indexMissing, 40))
374
+ L.push(` - \`${p}\``);
375
+ L.push('', `## 双语孪生 Twins`, '');
376
+ L.push(`- 角色分歧 roleDivergence(一侧存根一侧活页): ${d.roleDivergence.length}`);
377
+ for (const t of d.roleDivergence)
378
+ L.push(` - \`${t.path}\` — ${t.stubIn} 存根 / ${t.liveIn} 活页`);
379
+ const divergent = d.twinParity.filter((t) => t.divergent);
380
+ L.push(`- 内容分歧 divergent pairs: ${divergent.length} / ${d.twinParity.length}`);
381
+ for (const t of cap(divergent, 30))
382
+ L.push(` - \`${t.path}\` 长度比 ${t.lenRatio} 小节比 ${t.sectionCountRatio}${t.relatedMismatch ? ' 相关页尾缺失' : ''}`);
383
+ L.push(`- zh 页英文主导 zhEnglishDominant(违背中文为主): ${d.zhEnglishDominant.length}`);
384
+ for (const t of cap(d.zhEnglishDominant, 20))
385
+ L.push(` - \`${t.path}\` cjk ${(t.cjkRatio * 100).toFixed(1)}%`);
386
+ L.push('', `## 台账风险 Ledger claims(G4/G5/G6 具体机器事实但无核实戳): ${d.ledgerClaims.length}`, '');
387
+ for (const c of cap(d.ledgerClaims, 30))
388
+ L.push(`- \`${c.locale}/${c.path}\` (${c.genre}) ports:${c.ports} paths:${c.paths} cmds:${c.commands}`);
389
+ L.push('', '```json', JSON.stringify(r, null, 2), '```');
390
+ return L.join('\n');
391
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Contract of every skeleton constant:
9
9
  * - Rubric dimension C anatomy: H1 placeholder → status line
10
- * `**状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
10
+ * `**状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
11
11
  * → one-line scope (`This page answers:` / `本页回答:`) → tail
12
12
  * `Related Pages`/`相关页面` section with a real-link hint (SYN-9 fixed tail).
13
13
  * - wiki.js 2.x expression pieces only: blockquote admonitions
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Contract of every skeleton constant:
9
9
  * - Rubric dimension C anatomy: H1 placeholder → status line
10
- * `**状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
10
+ * `**状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
11
11
  * → one-line scope (`This page answers:` / `本页回答:`) → tail
12
12
  * `Related Pages`/`相关页面` section with a real-link hint (SYN-9 fixed tail).
13
13
  * - wiki.js 2.x expression pieces only: blockquote admonitions
@@ -26,7 +26,7 @@
26
26
  /** G1 — 事件/复盘页 (incident postmortem), zh. */
27
27
  export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
28
28
 
29
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
29
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
30
30
 
31
31
  **本页回答:** 一句话范围句(占位:本页记录哪次故障/事件的起因、影响与处置)
32
32
 
@@ -117,7 +117,7 @@ export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题
117
117
  /** G1 — incident postmortem, en (section-for-section twin of G1_ZH). */
118
118
  export const G1_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
119
119
 
120
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
120
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
121
121
 
122
122
  **This page answers:** one-line scope sentence (placeholder: which incident this page records, its impact and handling)
123
123
 
@@ -208,7 +208,7 @@ See footnote[^1].
208
208
  /** G2 — 对比/选型页 (comparison / selection), zh. */
209
209
  export const G2_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
210
210
 
211
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
211
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
212
212
 
213
213
  **本页回答:** 一句话范围句(占位:本页在哪些对象之间、按什么维度对比,结论是什么)
214
214
 
@@ -258,7 +258,7 @@ export const G2_ZH = `# 页面标题(占位:写完后替换为实际标题
258
258
  /** G2 — comparison / selection, en (section-for-section twin of G2_ZH). */
259
259
  export const G2_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
260
260
 
261
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
261
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
262
262
 
263
263
  **This page answers:** one-line scope sentence (placeholder: what is compared, on which dimensions, and the pick)
264
264
 
@@ -308,7 +308,7 @@ export const G2_EN = `# Page Title (placeholder: replace with the real title, mu
308
308
  /** G3 — 清单/参考页 (inventory / reference), zh. */
309
309
  export const G3_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
310
310
 
311
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
311
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
312
312
 
313
313
  **本页回答:** 一句话范围句(占位:本页收录哪类条目、覆盖到哪里、不覆盖什么)
314
314
 
@@ -343,7 +343,7 @@ export const G3_ZH = `# 页面标题(占位:写完后替换为实际标题
343
343
  /** G3 — inventory / reference, en (section-for-section twin of G3_ZH). */
344
344
  export const G3_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
345
345
 
346
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
346
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
347
347
 
348
348
  **This page answers:** one-line scope sentence (placeholder: which entries are covered, up to what boundary)
349
349
 
@@ -378,7 +378,7 @@ export const G3_EN = `# Page Title (placeholder: replace with the real title, mu
378
378
  /** G4 — 概念/原理解析页 (concept / explanation), zh. */
379
379
  export const G4_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
380
380
 
381
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
381
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
382
382
 
383
383
  **本页回答:** 一句话范围句(占位:本页解释哪个概念,读者看完能理解什么)
384
384
 
@@ -416,7 +416,7 @@ export const G4_ZH = `# 页面标题(占位:写完后替换为实际标题
416
416
  /** G4 — concept / explanation, en (section-for-section twin of G4_ZH). */
417
417
  export const G4_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
418
418
 
419
- **状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
419
+ **状态/Status**: draft <!-- 自检通过后改 Active/Historical/Superseded --> · **日期/Date**: YYYY-MM-DD
420
420
 
421
421
  **This page answers:** one-line scope sentence (placeholder: which concept is explained and what the reader will understand)
422
422
 
@@ -457,7 +457,7 @@ export const G4_EN = `# Page Title (placeholder: replace with the real title, mu
457
457
  * belongs to G1 event pages, linked from 变更记录. */
458
458
  export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题,须与页面 title 一致)
459
459
 
460
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
460
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
461
461
 
462
462
  **本页回答:** 当前部署状态(占位:写明范围)
463
463
 
@@ -508,7 +508,7 @@ export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题
508
508
  /** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
509
509
  export const G5_EN = `# Page Title (placeholder: replace with the real title, must match the page title)
510
510
 
511
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
511
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
512
512
 
513
513
  **This page answers:** what is deployed and running right now (placeholder: name the system and scope)
514
514
 
@@ -562,7 +562,7 @@ export const G5_EN = `# Page Title (placeholder: replace with the real title, mu
562
562
  * command lists in G3 pages, linked from 相关页面. */
563
563
  export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与页面 title 一致)
564
564
 
565
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
565
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
566
566
 
567
567
  **本页回答:** 如何完成某事(占位:写明具体目标)
568
568
 
@@ -611,7 +611,7 @@ export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与
611
611
  /** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
612
612
  export const G6_EN = `# How to Do X (placeholder: goal-titled heading, must match the page title)
613
613
 
614
- **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
614
+ **状态/Status**: draft <!-- 复核后改 Active / Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
615
615
 
616
616
  **This page answers:** how to finish one concrete task (placeholder: name the goal)
617
617
 
@@ -10,7 +10,7 @@ import { createPage } from '../wiki/pages.js';
10
10
  import { listPages, readPage } from '../wiki/pages.read.js';
11
11
  import { classifyGenre, genreSkeleton, GENRES } from '../templates/genres.js';
12
12
  import { evidenceSkeleton } from '../templates/evidence.js';
13
- import { checklistAdvisory, collisionAdvisory, enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
13
+ import { checklistAdvisory, collisionAdvisory, enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, publishGateRefusalJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
14
14
  const s = tool.schema;
15
15
  const ARGS_SHAPE = {
16
16
  path: s.string().describe('Wiki path, e.g. docs/guides/foo (first segment must NOT look like a locale code)'),
@@ -109,6 +109,9 @@ export function makeCreateTool(deps) {
109
109
  });
110
110
  }
111
111
  try {
112
+ const gate = publishGateRefusalJson(args.content, locale, args.path, deps.options.baseUrl);
113
+ if (gate !== null)
114
+ return gate;
112
115
  const collision = await collisionAdvice(deps, {
113
116
  tier,
114
117
  path: args.path,
@@ -9,8 +9,10 @@ import { TranslateError } from '../translate.js';
9
9
  import { buildChronology, filterRowsByPath } from '../chronology.js';
10
10
  import { getMap, refreshMapCache, CACHE_PATH } from '../map.js';
11
11
  import { buildMaintainReport, renderMaintainMarkdown } from '../maintain.js';
12
+ import { buildSurfaceReport, renderSurfaceMarkdown } from '../surface.js';
12
13
  import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
13
14
  import { listPages, readPage } from '../wiki/pages.read.js';
15
+ import { readPrimaryNav } from '../wiki/nav.js';
14
16
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
15
17
  const s = tool.schema;
16
18
  const TRANSLATE_ARGS = {
@@ -59,7 +61,7 @@ const MAP_ARGS = {
59
61
  action: s.enum(['show', 'refresh', 'timeline', 'maintain']).default('show'),
60
62
  days: s.number().int().positive().optional().describe('timeline: keep only rows updated within the last N days'),
61
63
  path: s.string().optional().describe('timeline: section/path prefix filter (e.g. ops)'),
62
- deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness stamps + redirect stubs) — one bounded read per row'),
64
+ deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness + stubs + broken/stacked links + twin parity + zh-first + unfinished skeletons + claim ledgers) — one bounded read per row, cached across both scans'),
63
65
  };
64
66
  const MapArgsSchema = s.object(MAP_ARGS);
65
67
  /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
@@ -69,34 +71,57 @@ const MapArgsSchema = s.object(MAP_ARGS);
69
71
  async function runMaintain(deps, mapDeps, snapshot, deep) {
70
72
  const client = deps.getClient();
71
73
  const tagIndex = new Map();
74
+ const liveInventory = [];
72
75
  const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
73
76
  for (const locale of locales) {
74
77
  for (const item of await listPages(client, { locale })) {
75
78
  tagIndex.set(`${item.locale}\u0000${item.path}`, item.tags);
79
+ liveInventory.push({ path: item.path, locale: item.locale, isPublished: item.isPublished });
76
80
  }
77
81
  }
78
82
  const rows = snapshot.rows.map((r) => ({ ...r, tags: tagIndex.get(`${r.locale}\u0000${r.path}`) ?? [] }));
83
+ const bodyCache = new Map();
79
84
  const readBody = deep
80
- ? async (path, locale) => {
81
- try {
82
- return (await readPage(client, path, locale))?.content ?? null;
83
- }
84
- catch (err) {
85
- // Unreadable page (invalid path / transport) is a scan miss, not a report failure.
86
- if (err instanceof PathValidationError)
87
- return null;
88
- throw err;
89
- }
85
+ ? (path, locale) => {
86
+ const key = `${locale}\u0000${path}`;
87
+ const hit = bodyCache.get(key);
88
+ if (hit !== undefined)
89
+ return hit;
90
+ const pending = (async () => {
91
+ try {
92
+ return (await readPage(client, path, locale))?.content ?? null;
93
+ }
94
+ catch (err) {
95
+ // Unreadable page (invalid path / transport) is a scan miss, not a report failure.
96
+ if (err instanceof PathValidationError)
97
+ return null;
98
+ throw err;
99
+ }
100
+ })();
101
+ bodyCache.set(key, pending);
102
+ return pending;
90
103
  }
91
104
  : undefined;
92
105
  const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
106
+ const nav = await readPrimaryNav(client);
107
+ const surface = await buildSurfaceReport({
108
+ rows,
109
+ generatedAt: report.generatedAt,
110
+ baseUrl: deps.options.baseUrl,
111
+ liveInventory,
112
+ nav,
113
+ deep,
114
+ readBody,
115
+ });
93
116
  return {
94
117
  action: 'maintain',
118
+ schema: 'historian.maintain.v3',
95
119
  deep: report.deep,
96
120
  generatedAt: report.generatedAt,
97
121
  rowCount: report.rowCount,
98
122
  report,
99
- markdown: renderMaintainMarkdown(report),
123
+ surface,
124
+ markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
100
125
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
101
126
  };
102
127
  }
@@ -109,8 +134,10 @@ export function makeMapTool(deps) {
109
134
  `show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
110
135
  `(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
111
136
  `optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
112
- `maintain runs the read-only curation sweep (twin gap, near-duplicate titles, staleness, diffusion/orphan ` +
113
- `candidates, tag vocab, section distribution; deep:true adds per-body freshness stamps + redirect stubs) and ` +
137
+ `maintain runs the read-only curation sweep + surface report (twin gap, near-duplicate titles, staleness, ` +
138
+ `diffusion/orphan candidates, tag vocab, section distribution, map-vs-live coverage, nav hygiene; ` +
139
+ `deep:true adds per-body freshness, stub reachability, broken/stacked/index-less links, twin parity, ` +
140
+ `unfinished skeletons, claim ledgers) and ` +
114
141
  `answers a markdown report with a stable-key JSON tail. ${URL_MANDATE}.`,
115
142
  args: MAP_ARGS,
116
143
  execute: async (raw) => {
@@ -126,3 +126,12 @@ export declare class ConfigError extends Error {
126
126
  export declare function sectionGuard(path: string, allowedSections: readonly string[] | undefined): string | null;
127
127
  /** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
128
128
  export declare function sectionRefusalJson(path: string, allowedSections: readonly string[] | undefined): ToolResult | null;
129
+ export declare class PublishGateError extends Error {
130
+ constructor(message: string);
131
+ }
132
+ /** Hard gate on front-tier writes: refuses the two shapes that shipped real
133
+ * incidents — a page claiming Active with TODO markers or empty skeleton
134
+ * sections, and a redirect stub whose body carries no clickable exit.
135
+ * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
136
+ * sanctioned work-in-progress escape hatch. Null = proceed. */
137
+ export declare function publishGateRefusalJson(content: string, locale: Locale, path: string, baseUrl: string): ToolResult | null;