opencode-wiki-historian 0.3.0 → 0.5.0

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,128 @@
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 type { MaintainRow } from './maintain.js';
15
+ import type { Locale } from './wiki/pages.read.js';
16
+ export declare const SURFACE_SCHEMA: "historian.surface.v1";
17
+ /** Minimal shape of a live `pages.list` row the surface diff needs. */
18
+ export interface LiveRow {
19
+ readonly path: string;
20
+ readonly locale: Locale;
21
+ readonly isPublished?: boolean;
22
+ readonly isPrivate?: boolean;
23
+ }
24
+ export interface SurfaceInput {
25
+ readonly rows: readonly MaintainRow[];
26
+ readonly generatedAt: string;
27
+ readonly baseUrl: string;
28
+ readonly liveInventory?: readonly LiveRow[];
29
+ readonly deep?: boolean;
30
+ readonly readBody?: (path: string, locale: Locale) => Promise<string | null>;
31
+ }
32
+ export interface SurfaceCoverage {
33
+ readonly liveRows: number;
34
+ readonly mapRows: number;
35
+ readonly missingFromMap: {
36
+ readonly path: string;
37
+ readonly locale: Locale;
38
+ }[];
39
+ readonly removedFromLive: number;
40
+ }
41
+ export interface SurfaceNav {
42
+ readonly machineSections: readonly string[];
43
+ readonly sectionLandingMissing: {
44
+ readonly dir: string;
45
+ readonly pagePaths: number;
46
+ }[];
47
+ }
48
+ export interface SurfaceUnfinished {
49
+ readonly path: string;
50
+ readonly locale: Locale;
51
+ readonly title: string;
52
+ readonly todo: number;
53
+ readonly emptySections: readonly string[];
54
+ readonly introEmpty: boolean;
55
+ readonly active: boolean;
56
+ readonly h1Mismatch: boolean;
57
+ }
58
+ export interface SurfaceStub {
59
+ readonly path: string;
60
+ readonly locale: Locale;
61
+ readonly target: string | null;
62
+ readonly clickable: boolean;
63
+ readonly targetLive: boolean;
64
+ }
65
+ export interface SurfaceLinks {
66
+ readonly broken: {
67
+ readonly from: string;
68
+ readonly locale: Locale;
69
+ readonly target: string;
70
+ }[];
71
+ readonly toStubs: {
72
+ readonly from: string;
73
+ readonly locale: Locale;
74
+ readonly target: string;
75
+ }[];
76
+ readonly sameTargetStacks: {
77
+ readonly from: string;
78
+ readonly locale: Locale;
79
+ readonly target: string;
80
+ readonly texts: number;
81
+ }[];
82
+ readonly orphanPages: readonly string[];
83
+ readonly indexMissing: readonly string[];
84
+ }
85
+ export interface SurfaceTwin {
86
+ readonly path: string;
87
+ readonly lenRatio: number;
88
+ readonly sectionCountRatio: number;
89
+ readonly relatedMismatch: boolean;
90
+ readonly divergent: boolean;
91
+ }
92
+ export interface SurfaceDeep {
93
+ readonly unfinished: SurfaceUnfinished[];
94
+ readonly stubs: SurfaceStub[];
95
+ readonly links: SurfaceLinks;
96
+ readonly roleDivergence: {
97
+ readonly path: string;
98
+ readonly stubIn: Locale;
99
+ readonly liveIn: Locale;
100
+ }[];
101
+ readonly twinParity: SurfaceTwin[];
102
+ readonly zhEnglishDominant: {
103
+ readonly path: string;
104
+ readonly cjkRatio: number;
105
+ }[];
106
+ readonly ledgerClaims: {
107
+ readonly path: string;
108
+ readonly locale: Locale;
109
+ readonly genre: string;
110
+ readonly ports: number;
111
+ readonly paths: number;
112
+ readonly commands: number;
113
+ }[];
114
+ }
115
+ export interface SurfaceReport {
116
+ readonly schema: typeof SURFACE_SCHEMA;
117
+ readonly generatedAt: string;
118
+ readonly deep: boolean;
119
+ readonly coverage: SurfaceCoverage | null;
120
+ readonly nav: SurfaceNav;
121
+ readonly tagsEmpty: {
122
+ readonly path: string;
123
+ readonly locale: Locale;
124
+ }[];
125
+ readonly deepReport: SurfaceDeep | null;
126
+ }
127
+ export declare function buildSurfaceReport(input: SurfaceInput): Promise<SurfaceReport>;
128
+ export declare function renderSurfaceMarkdown(r: SurfaceReport): string;
@@ -0,0 +1,359 @@
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
+ const ROOT_EXEMPT = new Set(['home', 'wiki-index']);
20
+ const visible = (r) => r.isPublished !== false && r.isPrivate !== true;
21
+ const rowKey = (path, locale) => `${locale}\u0000${path}`;
22
+ function isFrontPath(path) {
23
+ return !isInternalPath(path) && !path.startsWith('_sandbox/') && !path.startsWith('_data/');
24
+ }
25
+ // --- light tier ---------------------------------------------------------------
26
+ function buildNav(rows, live) {
27
+ const paths = new Set();
28
+ for (const r of rows)
29
+ paths.add(r.path);
30
+ for (const r of live ?? [])
31
+ paths.add(r.path);
32
+ const machineSections = [...new Set([...paths].map((p) => p.split('/')[0]))]
33
+ .filter((s) => MACHINE_SEG_RE.test(s))
34
+ .sort();
35
+ const perDir = new Map();
36
+ for (const p of paths) {
37
+ const seg = p.split('/');
38
+ if (seg.length < 2 || MACHINE_SEG_RE.test(seg[0]))
39
+ continue;
40
+ perDir.set(seg[0], (perDir.get(seg[0]) ?? 0) + 1);
41
+ }
42
+ const sectionLandingMissing = [...perDir.entries()]
43
+ .filter(([dir, n]) => n >= 2 && !paths.has(dir))
44
+ .map(([dir, pagePaths]) => ({ dir, pagePaths }))
45
+ .sort((a, b) => b.pagePaths - a.pagePaths || a.dir.localeCompare(b.dir));
46
+ return { machineSections, sectionLandingMissing };
47
+ }
48
+ function buildCoverage(rows, live) {
49
+ if (live === undefined)
50
+ return null;
51
+ const mapKeys = new Set(rows.map((r) => rowKey(r.path, r.locale)));
52
+ const liveVisible = live.filter(visible);
53
+ const liveKeys = new Set(liveVisible.map((r) => rowKey(r.path, r.locale)));
54
+ const missingFromMap = liveVisible
55
+ .filter((r) => !mapKeys.has(rowKey(r.path, r.locale)))
56
+ .map((r) => ({ path: r.path, locale: r.locale }))
57
+ .sort((a, b) => a.path.localeCompare(b.path) || a.locale.localeCompare(b.locale));
58
+ let removedFromLive = 0;
59
+ for (const k of mapKeys)
60
+ if (!liveKeys.has(k))
61
+ removedFromLive += 1;
62
+ return { liveRows: liveVisible.length, mapRows: rows.length, missingFromMap, removedFromLive };
63
+ }
64
+ const STACK_MD_RE = /\[([^\]]+)\]\((?:https?:\/\/[^\s)]+|\/[^\s)]*)\)/g;
65
+ function anchorTexts(body) {
66
+ const out = [];
67
+ STACK_MD_RE.lastIndex = 0;
68
+ let m;
69
+ while ((m = STACK_MD_RE.exec(body)) !== null)
70
+ out.push({ raw: m[0], text: m[1].trim() });
71
+ return out;
72
+ }
73
+ function resolveTargetPath(baseUrl, rawHref) {
74
+ let t = rawHref.trim();
75
+ if (t === '' || t.startsWith('#') || /^(mailto:|javascript:)/i.test(t))
76
+ return null;
77
+ const host = baseUrl.replace(/\/+$/, '');
78
+ if (/^https?:\/\//i.test(t)) {
79
+ if (!t.toLowerCase().startsWith(host.toLowerCase()))
80
+ return null;
81
+ t = t.slice(host.length);
82
+ }
83
+ if (/\.(png|jpe?g|gif|svg|webp|pdf|zip|css|js)\b/i.test(t))
84
+ return null;
85
+ t = t.split('#')[0].split('?')[0];
86
+ t = t.replace(/^\/+/, '');
87
+ if (t === '')
88
+ return null;
89
+ if (t.startsWith('zh/'))
90
+ return { path: t.slice(3), locale: 'zh' };
91
+ if (t.startsWith('en/'))
92
+ return { path: t.slice(3), locale: 'en' };
93
+ return { path: t, locale: 'en' };
94
+ }
95
+ async function scanBodies(input) {
96
+ if (input.readBody === undefined)
97
+ return [];
98
+ const targets = input.rows.filter((r) => !isInternalPath(r.path));
99
+ const results = [];
100
+ const queue = [...targets];
101
+ const worker = async () => {
102
+ for (;;) {
103
+ const row = queue.shift();
104
+ if (row === undefined)
105
+ return;
106
+ const body = (await input.readBody?.(row.path, row.locale)) ?? '';
107
+ results.push({ row, lint: body === '' ? null : lintBody(body, { locale: row.locale, baseUrl: input.baseUrl, title: row.title }), body });
108
+ }
109
+ };
110
+ await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
111
+ return results;
112
+ }
113
+ function buildDeep(input, scans) {
114
+ if (scans.length === 0)
115
+ return null;
116
+ const ok = scans.filter((s) => s.lint !== null);
117
+ const unfinished = ok
118
+ .filter((s) => !s.lint.isRedirectStub && isFrontPath(s.row.path))
119
+ .filter((s) => s.lint.todoMarkers > 0 || s.lint.emptySections.length > 0 || s.lint.introEmpty)
120
+ .map((s) => ({
121
+ path: s.row.path,
122
+ locale: s.row.locale,
123
+ title: s.row.title,
124
+ todo: s.lint.todoMarkers,
125
+ emptySections: s.lint.emptySections,
126
+ introEmpty: s.lint.introEmpty,
127
+ active: s.lint.state === 'active',
128
+ h1Mismatch: s.lint.h1 !== null && s.lint.h1.trim() !== s.row.title.trim(),
129
+ }))
130
+ .sort((a, b) => Number(b.active) - Number(a.active) || b.todo + b.emptySections.length - (a.todo + a.emptySections.length));
131
+ const pathSet = new Set();
132
+ for (const r of input.rows)
133
+ pathSet.add(r.path);
134
+ for (const r of input.liveInventory ?? [])
135
+ pathSet.add(r.path);
136
+ const stubPaths = new Map();
137
+ const stubs = [];
138
+ for (const s of ok.filter((x) => x.lint.isRedirectStub)) {
139
+ const target = s.lint.redirectTarget === null ? null : resolveTargetPath(input.baseUrl, s.lint.redirectTarget)?.path ?? null;
140
+ stubs.push({ path: s.row.path, locale: s.row.locale, target: s.lint.redirectTarget, clickable: s.lint.stubHasLink, targetLive: target !== null && pathSet.has(target) });
141
+ if (!stubPaths.has(s.row.path))
142
+ stubPaths.set(s.row.path, new Set());
143
+ stubPaths.get(s.row.path).add(s.row.locale);
144
+ }
145
+ stubs.sort((a, b) => Number(a.clickable) - Number(b.clickable) || a.path.localeCompare(b.path));
146
+ const broken = [];
147
+ const toStubs = [];
148
+ const inbound = new Map();
149
+ const indexTargets = new Set();
150
+ const sameTargetStacks = [];
151
+ for (const s of ok) {
152
+ const isIndex = s.row.path === 'wiki-index';
153
+ const byTarget = new Map();
154
+ for (const a of anchorTexts(s.body)) {
155
+ const t = resolveTargetPath(input.baseUrl, a.raw.slice(a.raw.indexOf('](') + 2, -1));
156
+ if (t === null)
157
+ continue;
158
+ if (!byTarget.has(t.path))
159
+ byTarget.set(t.path, new Set());
160
+ byTarget.get(t.path).add(a.text);
161
+ }
162
+ for (const link of s.lint.links) {
163
+ if (!isFrontPath(link.path) || ROOT_EXEMPT.has(link.path))
164
+ continue;
165
+ if (!link.path.startsWith('_sandbox/'))
166
+ inbound.set(link.path, (inbound.get(link.path) ?? 0) + 1);
167
+ if (isIndex)
168
+ indexTargets.add(link.path);
169
+ if (link.path === s.row.path)
170
+ continue;
171
+ if (!pathSet.has(link.path)) {
172
+ if (!broken.some((b) => b.from === s.row.path && b.locale === s.row.locale && b.target === link.path)) {
173
+ broken.push({ from: s.row.path, locale: s.row.locale, target: link.path });
174
+ }
175
+ }
176
+ else if (stubPaths.has(link.path) && !broken.some((b) => b.target === link.path)) {
177
+ toStubs.push({ from: s.row.path, locale: s.row.locale, target: link.path });
178
+ }
179
+ }
180
+ for (const [target, texts] of byTarget) {
181
+ if (texts.size >= 3 && pathSet.has(target))
182
+ sameTargetStacks.push({ from: s.row.path, locale: s.row.locale, target, texts: texts.size });
183
+ }
184
+ }
185
+ const liveByPathLocale = new Map();
186
+ for (const s of ok)
187
+ liveByPathLocale.set(rowKey(s.row.path, s.row.locale), s);
188
+ const roleDivergence = [];
189
+ const twinParity = [];
190
+ const pathsAll = new Set();
191
+ for (const s of ok)
192
+ pathsAll.add(s.row.path);
193
+ for (const p of [...pathsAll].sort()) {
194
+ const en = liveByPathLocale.get(rowKey(p, 'en'));
195
+ const zh = liveByPathLocale.get(rowKey(p, 'zh'));
196
+ const enStub = stubPaths.get(p)?.has('en') ?? false;
197
+ const zhStub = stubPaths.get(p)?.has('zh') ?? false;
198
+ if (enStub !== zhStub && en !== undefined && zh !== undefined) {
199
+ roleDivergence.push({ path: p, stubIn: enStub ? 'en' : 'zh', liveIn: enStub ? 'zh' : 'en' });
200
+ }
201
+ if (en !== undefined && zh !== undefined && !enStub && !zhStub && isFrontPath(p)) {
202
+ const enLen = (en.body.match(/\S/g) ?? []).length;
203
+ const zhLen = (zh.body.match(/\S/g) ?? []).length;
204
+ if (enLen === 0 || zhLen === 0)
205
+ continue;
206
+ // Length must be script-weighted: a CJK glyph carries ~3x the information
207
+ // of a latin char, so raw lengths flagged faithful zh translations as
208
+ // "truncated" (0.47 ratio at 1.0 section parity in the live scan).
209
+ const eff = (n, cjkRatio) => n * (1 + 2 * cjkRatio);
210
+ // Structural, not literal: en/zh heading STRINGS are translations, so
211
+ // text-jaccard measured 0 on 85/98 live pairs (pure noise). What survives
212
+ // translation: level>=2 section count + Related Pages tail (SYN-9).
213
+ const h2 = (l) => (l?.headings ?? []).filter((h) => h.level >= 2).length;
214
+ const nEn = h2(en.lint);
215
+ const nZh = h2(zh.lint);
216
+ const sectionCountRatio = Math.max(nEn, nZh) === 0 ? 1 : Math.min(nEn, nZh) / Math.max(nEn, nZh);
217
+ const relatedMismatch = (en.lint?.hasRelatedPages ?? false) !== (zh.lint?.hasRelatedPages ?? false);
218
+ const lenRatio = Math.min(eff(enLen, en.lint?.cjkRatio ?? 0), eff(zhLen, zh.lint?.cjkRatio ?? 0))
219
+ / Math.max(eff(enLen, en.lint?.cjkRatio ?? 0), eff(zhLen, zh.lint?.cjkRatio ?? 0));
220
+ twinParity.push({
221
+ path: p,
222
+ lenRatio: Number(lenRatio.toFixed(2)),
223
+ sectionCountRatio: Number(sectionCountRatio.toFixed(2)),
224
+ relatedMismatch,
225
+ divergent: sectionCountRatio < 0.6 || lenRatio < 0.5 || relatedMismatch,
226
+ });
227
+ }
228
+ }
229
+ twinParity.sort((a, b) => Number(b.divergent) - Number(a.divergent) || a.lenRatio - b.lenRatio);
230
+ const zhEnglishDominant = ok
231
+ .filter((s) => s.row.locale === 'zh' && isFrontPath(s.row.path) && !s.lint.isRedirectStub)
232
+ .filter((s) => (s.body.match(/\S/g) ?? []).length > 400 && s.lint.cjkRatio < 0.06)
233
+ .map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
234
+ const ledgerClaims = [];
235
+ for (const s of ok) {
236
+ if (s.lint.isRedirectStub || !isFrontPath(s.row.path) || s.lint.hasStamp)
237
+ continue;
238
+ const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
239
+ if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
240
+ continue;
241
+ if (s.lint.claimTotal < 3)
242
+ continue;
243
+ 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 });
244
+ }
245
+ ledgerClaims.sort((a, b) => b.ports + b.paths + b.commands - (a.ports + a.paths + a.commands));
246
+ const orphanPages = ok
247
+ .filter((s) => isFrontPath(s.row.path) && !s.lint.isRedirectStub && !ROOT_EXEMPT.has(s.row.path))
248
+ .map((s) => s.row.path)
249
+ .filter((p, i, arr) => arr.indexOf(p) === i && (inbound.get(p) ?? 0) === 0)
250
+ .sort();
251
+ const indexMissing = [...new Set(ok.filter((s) => isFrontPath(s.row.path)).map((s) => s.row.path))]
252
+ .filter((p) => !ROOT_EXEMPT.has(p) && !(stubPaths.get(p)?.has('en') ?? false) && !indexTargets.has(p))
253
+ .sort();
254
+ return {
255
+ unfinished,
256
+ stubs,
257
+ links: { broken, toStubs, sameTargetStacks, orphanPages, indexMissing },
258
+ roleDivergence,
259
+ twinParity,
260
+ zhEnglishDominant,
261
+ ledgerClaims,
262
+ };
263
+ }
264
+ // --- entry --------------------------------------------------------------------
265
+ export async function buildSurfaceReport(input) {
266
+ const scans = input.deep === true ? await scanBodies(input) : [];
267
+ return {
268
+ schema: SURFACE_SCHEMA,
269
+ generatedAt: input.generatedAt,
270
+ deep: input.deep === true,
271
+ coverage: buildCoverage(input.rows, input.liveInventory),
272
+ nav: buildNav(input.rows, input.liveInventory),
273
+ tagsEmpty: input.rows
274
+ .filter((r) => isFrontPath(r.path) && (r.tags?.length ?? 0) === 0)
275
+ .map((r) => ({ path: r.path, locale: r.locale }))
276
+ .sort((a, b) => a.path.localeCompare(b.path)),
277
+ deepReport: buildDeep(input, scans),
278
+ };
279
+ }
280
+ // --- render -------------------------------------------------------------------
281
+ const cap = (arr, n) => arr.slice(0, n);
282
+ export function renderSurfaceMarkdown(r) {
283
+ const L = [];
284
+ L.push(`# 界面健康 Surface Report (${r.deep ? 'deep' : 'light'})`, '');
285
+ L.push(`> ${r.generatedAt} · schema ${r.schema}`, '');
286
+ L.push('## 覆盖 Coverage(地图 vs 实时)', '');
287
+ if (r.coverage === null)
288
+ L.push('- (未提供 live inventory — 由 `maintain` 工具自动采集)');
289
+ else {
290
+ L.push(`- live ${r.coverage.liveRows} 行 / map ${r.coverage.mapRows} 行 · 地图外 missingFromMap ${r.coverage.missingFromMap.length} · 地图内已消失 removedFromLive ${r.coverage.removedFromLive}`);
291
+ for (const m of cap(r.coverage.missingFromMap, 40))
292
+ L.push(` - \`${m.locale}/${m.path}\``);
293
+ }
294
+ L.push('', '## 导航 Nav(动态侧栏镜像)', '');
295
+ L.push(`- 机器命名空间暴露 machineSections: ${r.nav.machineSections.map((s) => `\`${s}/\``).join(' ') || 'none'}`);
296
+ if (r.nav.sectionLandingMissing.length > 0) {
297
+ L.push(`- 落地页缺失 sectionLandingMissing(面包屑 404 / 空目录页):`);
298
+ for (const s of r.nav.sectionLandingMissing)
299
+ L.push(` - \`/${s.dir}\` — ${s.pagePaths} 页在此目录下`);
300
+ }
301
+ L.push('', `## 元数据卫生 Tags(前台空标签 ${r.tagsEmpty.length})`, '');
302
+ if (r.tagsEmpty.length === 0)
303
+ L.push('- none');
304
+ else {
305
+ const byPath = new Map();
306
+ for (const t of r.tagsEmpty)
307
+ byPath.set(t.path, [...(byPath.get(t.path) ?? []), t.locale]);
308
+ for (const [p, ls] of cap([...byPath.entries()], 40))
309
+ L.push(`- \`${p}\` (${ls.join(',')})`);
310
+ }
311
+ if (r.deepReport === null) {
312
+ L.push('', '## Deep —(light 扫描未含正文级检测;用 deep:true)', '');
313
+ L.push('', '```json', JSON.stringify(r, null, 2), '```');
314
+ return L.join('\n');
315
+ }
316
+ const d = r.deepReport;
317
+ L.push('', `## 未完成正文 Unfinished skeletons (${d.unfinished.length})`, '');
318
+ L.push('| Path | 语言 | TODO | 空节 | 导言空 | Active谎报 | H1≠标题 |', '| --- | --- | --- | --- | --- | --- | --- |');
319
+ for (const u of cap(d.unfinished, 50)) {
320
+ L.push(`| \`${u.path}\` | ${u.locale} | ${u.todo} | ${u.emptySections.length} | ${u.introEmpty ? '✓' : ''} | ${u.active ? '**✓**' : ''} | ${u.h1Mismatch ? '✓' : ''} |`);
321
+ }
322
+ L.push('', `## 重定向存根 Redirect stubs (${d.stubs.length})`, '');
323
+ const dead = d.stubs.filter((s) => !s.clickable || !s.targetLive);
324
+ L.push(`- 无出口或死目标 dead/no-exit: ${dead.length}${dead.length > 0 ? '' : ' ✓'}`);
325
+ for (const s of cap(dead, 30))
326
+ L.push(` - \`${s.locale}/${s.path}\` → ${s.target ?? '?'} ${s.clickable ? '' : '(正文无可点击链接)'}${s.targetLive ? '' : '(目标不存在)'}`);
327
+ L.push('', '## 链接 Links', '');
328
+ L.push(`- 断链 broken: ${d.links.broken.length}`);
329
+ for (const b of cap(d.links.broken, 30))
330
+ L.push(` - \`${b.locale}/${b.from}\` → \`${b.target}\``);
331
+ L.push(`- 指向存根 toStubs: ${d.links.toStubs.length}`);
332
+ for (const b of cap(d.links.toStubs, 20))
333
+ L.push(` - \`${b.locale}/${b.from}\` → 存根 \`${b.target}\``);
334
+ L.push(`- 同目标堆叠 sameTargetStacks(≥3 锚文本指同页): ${d.links.sameTargetStacks.length}`);
335
+ for (const b of cap(d.links.sameTargetStacks, 20))
336
+ L.push(` - \`${b.locale}/${b.from}\` × ${b.texts} → \`${b.target}\``);
337
+ L.push(`- 孤儿 orphanPages(全库无任何入链): ${d.links.orphanPages.length}`);
338
+ for (const p of cap(d.links.orphanPages, 30))
339
+ L.push(` - \`${p}\``);
340
+ L.push(`- 索引缺席 indexMissing(wiki-index 未收录): ${d.links.indexMissing.length}`);
341
+ for (const p of cap(d.links.indexMissing, 40))
342
+ L.push(` - \`${p}\``);
343
+ L.push('', `## 双语孪生 Twins`, '');
344
+ L.push(`- 角色分歧 roleDivergence(一侧存根一侧活页): ${d.roleDivergence.length}`);
345
+ for (const t of d.roleDivergence)
346
+ L.push(` - \`${t.path}\` — ${t.stubIn} 存根 / ${t.liveIn} 活页`);
347
+ const divergent = d.twinParity.filter((t) => t.divergent);
348
+ L.push(`- 内容分歧 divergent pairs: ${divergent.length} / ${d.twinParity.length}`);
349
+ for (const t of cap(divergent, 30))
350
+ L.push(` - \`${t.path}\` 长度比 ${t.lenRatio} 小节比 ${t.sectionCountRatio}${t.relatedMismatch ? ' 相关页尾缺失' : ''}`);
351
+ L.push(`- zh 页英文主导 zhEnglishDominant(违背中文为主): ${d.zhEnglishDominant.length}`);
352
+ for (const t of cap(d.zhEnglishDominant, 20))
353
+ L.push(` - \`${t.path}\` cjk ${(t.cjkRatio * 100).toFixed(1)}%`);
354
+ L.push('', `## 台账风险 Ledger claims(G4/G5/G6 具体机器事实但无核实戳): ${d.ledgerClaims.length}`, '');
355
+ for (const c of cap(d.ledgerClaims, 30))
356
+ L.push(`- \`${c.locale}/${c.path}\` (${c.genre}) ports:${c.ports} paths:${c.paths} cmds:${c.commands}`);
357
+ L.push('', '```json', JSON.stringify(r, null, 2), '```');
358
+ return L.join('\n');
359
+ }
@@ -24,11 +24,11 @@
24
24
  * see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
25
25
  * import everything from this barrel.
26
26
  */
27
- export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
28
- export type Genre = 'G1' | 'G2' | 'G3' | 'G4' | 'G5';
27
+ export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
28
+ export type Genre = 'G1' | 'G2' | 'G3' | 'G4' | 'G5' | 'G6';
29
29
  export type GenreLang = 'en' | 'zh';
30
30
  export type Confidence = 'high' | 'medium' | 'low';
31
- export declare const GENRES: readonly Genre[];
31
+ export declare const GENRES: readonly ["G1", "G2", "G3", "G4", "G5", "G6"];
32
32
  export interface ClassifyInput {
33
33
  readonly title: string;
34
34
  readonly body: string;
@@ -24,11 +24,11 @@
24
24
  * see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
25
25
  * import everything from this barrel.
26
26
  */
27
- import { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
27
+ import { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
28
28
  // Contract re-exports: raw skeleton data stays reachable through the barrel
29
29
  // (todo 13 renders these double-checked strings into skill references).
30
- export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
31
- export const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
30
+ export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, G6_EN, G6_ZH, } from './skeletons.js';
31
+ export const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5', 'G6'];
32
32
  // --- genreSkeleton ----------------------------------------------------------
33
33
  const SKELETONS = {
34
34
  G1: { en: G1_EN, zh: G1_ZH },
@@ -36,6 +36,7 @@ const SKELETONS = {
36
36
  G3: { en: G3_EN, zh: G3_ZH },
37
37
  G4: { en: G4_EN, zh: G4_ZH },
38
38
  G5: { en: G5_EN, zh: G5_ZH },
39
+ G6: { en: G6_EN, zh: G6_ZH },
39
40
  };
40
41
  /** Full markdown skeleton for a genre × language pair. Pure string data —
41
42
  * the author copies it, replaces placeholders, and fills the commented slots. */
@@ -60,6 +61,9 @@ const GENRE_KEYWORDS = {
60
61
  // Deployed-state cues only — bare 部署/版本 would collide with G3 "部署清单"
61
62
  // and general changelog talk, regressing existing corpus classifications.
62
63
  G5: ['现状卡', '当前状态', '已部署', '部署物', '现役', '上线', '端口', '上次核实', '失效策略', '验证命令', 'current state', 'deployed', 'last verified', 'running now'],
64
+ // Goal-titled how-to cues only — bare 步骤/操作 would steal G3 清单 pages
65
+ // that merely mention 验证步骤 and scratch notes titled 今日操作记录.
66
+ G6: ['如何', '怎么', '上手', '指南', '操作手册', '操作步骤', 'how to', 'steps to', 'runbook'],
63
67
  };
64
68
  function escapeRegex(s) {
65
69
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -155,6 +159,28 @@ const G5_CHECKLIST_VARIANTS = {
155
159
  appliesTo: ['G5'],
156
160
  },
157
161
  };
162
+ /** G6 swaps the genre-specific items 4–6 for how-to gates (goal-titled H1,
163
+ * step triples, D4 freshness metadata); everything else is shared. */
164
+ const G6_CHECKLIST_VARIANTS = {
165
+ 4: {
166
+ id: 4,
167
+ label: '标题是目标句式 "How to X" / "如何/怎么做X"(goal-titled H1: the page name IS the reader\'s goal)',
168
+ kind: 'genre-specific',
169
+ appliesTo: ['G6'],
170
+ },
171
+ 5: {
172
+ id: 5,
173
+ label: '操作步骤每条三段:动作 + 预期结果 + 失败处置(every numbered step carries action + expected result + on-failure handling)',
174
+ kind: 'genre-specific',
175
+ appliesTo: ['G6'],
176
+ },
177
+ 6: {
178
+ id: 6,
179
+ label: '元数据表含「上次核实」与「复核周期」行(metadata table carries last-verified + review-by rows; 被取代于 once retired)',
180
+ kind: 'genre-specific',
181
+ appliesTo: ['G6'],
182
+ },
183
+ };
158
184
  export function selfReviewChecklist(genre) {
159
185
  if (!GENRES.includes(genre)) {
160
186
  throw new Error(`unknown genre: ${genre}`);
@@ -221,8 +247,9 @@ export function selfReviewChecklist(genre) {
221
247
  appliesTo: 'all',
222
248
  },
223
249
  ];
224
- if (genre === 'G5') {
225
- return items.map((item) => G5_CHECKLIST_VARIANTS[item.id] ?? item);
250
+ const variants = genre === 'G5' ? G5_CHECKLIST_VARIANTS : genre === 'G6' ? G6_CHECKLIST_VARIANTS : undefined;
251
+ if (variants !== undefined) {
252
+ return items.map((item) => variants[item.id] ?? item);
226
253
  }
227
254
  return items;
228
255
  }
@@ -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
@@ -46,3 +46,10 @@ export declare const G4_EN: string;
46
46
  export declare const G5_ZH: string;
47
47
  /** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
48
48
  export declare const G5_EN: string;
49
+ /** G6 — 操作手册/how-to 页 (goal-titled operational manual), zh. Diátaxis doing
50
+ * leg: a reader with one goal follows the numbered steps to the outcome; every
51
+ * step carries 动作 + 预期结果 + 失败处置. Concepts stay in G4 pages, raw
52
+ * command lists in G3 pages, linked from 相关页面. */
53
+ export declare const G6_ZH: string;
54
+ /** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
55
+ export declare const G6_EN: string;