opencode-wiki-historian 0.4.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.
- package/dist/lint.d.ts +79 -0
- package/dist/lint.js +254 -0
- package/dist/surface.d.ts +128 -0
- package/dist/surface.js +359 -0
- package/dist/templates/skeletons.d.ts +1 -1
- package/dist/templates/skeletons.js +13 -13
- package/dist/tools/create.js +4 -1
- package/dist/tools/local.js +38 -14
- package/dist/tools/shared.d.ts +9 -0
- package/dist/tools/shared.js +23 -0
- package/dist/tools/write.js +6 -1
- package/package.json +1 -1
- package/skills/historian/SKILL.md +21 -1
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body lint: one fence-aware pass over a page body producing the structural
|
|
3
|
+
* facts every hard gate and surface detector reuses (plan v0.5.0 #6).
|
|
4
|
+
* Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
|
|
5
|
+
* tools lint drafts before they hit the API. This is the module the
|
|
6
|
+
* architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
|
|
7
|
+
* here was optional before, which is exactly how a page with five empty
|
|
8
|
+
* sections shipped for three days claiming `状态: Active`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Locale } from './wiki/pages.read.js';
|
|
11
|
+
/** Machine rule keys; the human report renders zh labels from these. */
|
|
12
|
+
export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton';
|
|
13
|
+
export interface LintFinding {
|
|
14
|
+
readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp';
|
|
15
|
+
readonly detail: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PageLink {
|
|
18
|
+
/** Normalized wiki path (locale prefix stripped, leading `/` removed, no anchor/query). */
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly locale: Locale;
|
|
21
|
+
/** True when the raw target was a same-page anchor (`#…`) — never resolved. */
|
|
22
|
+
readonly anchor: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ClaimCounts {
|
|
25
|
+
readonly ports: number;
|
|
26
|
+
readonly paths: number;
|
|
27
|
+
readonly commands: number;
|
|
28
|
+
}
|
|
29
|
+
export interface BodyLint {
|
|
30
|
+
readonly isRedirectStub: boolean;
|
|
31
|
+
readonly redirectTarget: string | null;
|
|
32
|
+
readonly stubHasLink: boolean;
|
|
33
|
+
readonly hasStateBlock: boolean;
|
|
34
|
+
readonly state: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
|
|
35
|
+
readonly todoMarkers: number;
|
|
36
|
+
/** Heading text of sections whose body (before the next heading ≤ level) is empty. */
|
|
37
|
+
readonly emptySections: readonly string[];
|
|
38
|
+
readonly introEmpty: boolean;
|
|
39
|
+
readonly hasRelatedPages: boolean;
|
|
40
|
+
readonly h1: string | null;
|
|
41
|
+
/** All heading spans in document order (twin-parity + structure checks). */
|
|
42
|
+
readonly headings: readonly HeadingSpan[];
|
|
43
|
+
readonly links: readonly PageLink[];
|
|
44
|
+
readonly hasStamp: boolean;
|
|
45
|
+
readonly claims: ClaimCounts;
|
|
46
|
+
readonly claimTotal: number;
|
|
47
|
+
/** CJK chars / non-whitespace chars, zh body only meaningful; 0..1. */
|
|
48
|
+
readonly cjkRatio: number;
|
|
49
|
+
}
|
|
50
|
+
export interface HeadingSpan {
|
|
51
|
+
readonly level: number;
|
|
52
|
+
readonly heading: string;
|
|
53
|
+
/** Subtree body: weighted length until the next heading ≤ level, INCLUDING
|
|
54
|
+
* descendant sections' content (masked+comment-free). */
|
|
55
|
+
readonly bodyChars: number;
|
|
56
|
+
/** Direct body only: weighted length until the next heading at ANY level.
|
|
57
|
+
* Used for the intro ratio so an H1 span never swallows the whole page. */
|
|
58
|
+
readonly directChars: number;
|
|
59
|
+
}
|
|
60
|
+
/** Section headings (##+) whose SUBTREE holds no content. A parent that groups
|
|
61
|
+
* non-empty subsections is an outline container, not an unfinished product;
|
|
62
|
+
* only wholly-empty subtrees indicate an unfilled skeleton section. */
|
|
63
|
+
export declare function emptySectionsOf(headings: readonly HeadingSpan[]): readonly string[];
|
|
64
|
+
export interface LinkScanOpts {
|
|
65
|
+
readonly baseUrl: string;
|
|
66
|
+
}
|
|
67
|
+
/** Extract internal page links (relative wiki paths + same-host absolute URLs). */
|
|
68
|
+
export declare function extractLinks(masked: string, opts: LinkScanOpts): readonly PageLink[];
|
|
69
|
+
export interface LintOpts {
|
|
70
|
+
readonly locale: Locale;
|
|
71
|
+
readonly baseUrl: string;
|
|
72
|
+
/** Page title for the h1-mismatch check. */
|
|
73
|
+
readonly title?: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function lintBody(body: string, opts: LintOpts): BodyLint;
|
|
76
|
+
/** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
|
|
77
|
+
export declare function h1TitleMismatch(lint: BodyLint, title: string | undefined): boolean;
|
|
78
|
+
/** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
|
|
79
|
+
export declare function publishGateViolations(lint: BodyLint): readonly GateViolation[];
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body lint: one fence-aware pass over a page body producing the structural
|
|
3
|
+
* facts every hard gate and surface detector reuses (plan v0.5.0 #6).
|
|
4
|
+
* Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
|
|
5
|
+
* tools lint drafts before they hit the API. This is the module the
|
|
6
|
+
* architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
|
|
7
|
+
* here was optional before, which is exactly how a page with five empty
|
|
8
|
+
* sections shipped for three days claiming `状态: Active`.
|
|
9
|
+
*/
|
|
10
|
+
// --- fence + comment masking ----------------------------------------------------
|
|
11
|
+
/** Blank out fenced code-block interiors (keep line structure) so markers,
|
|
12
|
+
* headings and links inside code fences never count (SYN fence rule). */
|
|
13
|
+
function maskFences(body) {
|
|
14
|
+
const lines = body.split('\n');
|
|
15
|
+
let fence = null;
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
const line = lines[i];
|
|
18
|
+
const m = /^(`{3,}|~{3,})/.exec(line.trimStart());
|
|
19
|
+
if (fence === null && m !== null) {
|
|
20
|
+
fence = m[1][0];
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (fence !== null) {
|
|
24
|
+
if (line.trimStart().startsWith(fence.repeat(3))) {
|
|
25
|
+
fence = null;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
lines[i] = '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
35
|
+
// --- redirect stub --------------------------------------------------------------
|
|
36
|
+
const REDIRECT_LINE_RE = /^>\s*Redirect:\s*(\S.*?)\s*$/im;
|
|
37
|
+
function redirectTarget(masked) {
|
|
38
|
+
const m = REDIRECT_LINE_RE.exec(masked);
|
|
39
|
+
return m !== null ? m[1] : null;
|
|
40
|
+
}
|
|
41
|
+
/** Clickable exit (Issue #5.1): a stub only lives if it carries at least one
|
|
42
|
+
* real internal link — a bare `<code>` path or an in-page anchor is a dead
|
|
43
|
+
* end for the reader who lands here. Wrong-target links belong to the
|
|
44
|
+
* dead-link scanner, not this gate. */
|
|
45
|
+
function hasClickableExit(visibleNoAnchors) {
|
|
46
|
+
return visibleNoAnchors > 0;
|
|
47
|
+
}
|
|
48
|
+
// --- status block -----------------------------------------------------------------
|
|
49
|
+
const STATE_LINE_RE = /(?:^|\n)\s*\*{0,2}\s*(?:状态\s*\/\s*Status|状态|Status)\s*\*{0,2}\s*[::]\s*([A-Za-z\u4e00-\u9fff][^\n·|<]*)/i;
|
|
50
|
+
function parseState(maskedNoComments) {
|
|
51
|
+
const m = STATE_LINE_RE.exec(maskedNoComments);
|
|
52
|
+
if (m === null)
|
|
53
|
+
return { has: false, state: null };
|
|
54
|
+
const v = m[1].trim().toLowerCase();
|
|
55
|
+
if (v.startsWith('active'))
|
|
56
|
+
return { has: true, state: 'active' };
|
|
57
|
+
if (v.startsWith('draft'))
|
|
58
|
+
return { has: true, state: 'draft' };
|
|
59
|
+
if (v.startsWith('superseded'))
|
|
60
|
+
return { has: true, state: 'superseded' };
|
|
61
|
+
if (v.startsWith('deprecated'))
|
|
62
|
+
return { has: true, state: 'deprecated' };
|
|
63
|
+
return { has: true, state: null };
|
|
64
|
+
}
|
|
65
|
+
// --- markers, headings, intro -------------------------------------------------------
|
|
66
|
+
const TODO_COMMENT_RE = /(TODO|TBD|PLACEHOLDER|占位)/i;
|
|
67
|
+
const LITERAL_TODO_RE = /\bTODO:/g;
|
|
68
|
+
function countTodoMarkers(masked) {
|
|
69
|
+
let n = 0;
|
|
70
|
+
for (const m of masked.matchAll(COMMENT_RE)) {
|
|
71
|
+
if (TODO_COMMENT_RE.test(m[0]))
|
|
72
|
+
n++;
|
|
73
|
+
}
|
|
74
|
+
// literal TODO: outside comments (comments already masked to '' for this pass)
|
|
75
|
+
const noComments = masked.replace(COMMENT_RE, '');
|
|
76
|
+
n += (noComments.match(LITERAL_TODO_RE) ?? []).length;
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*$/;
|
|
80
|
+
const STATUS_BOILERPLATE_RE = /状态\s*\/\s*Status|日期\s*\/\s*Date|本页回答|This page answers/i;
|
|
81
|
+
/** Parse headings + emptiness over masked, comment-stripped text. Also returns
|
|
82
|
+
* the intro span (before the first heading) for the 导言占比 detector. */
|
|
83
|
+
function parseStructure(masked) {
|
|
84
|
+
const noComments = masked.replace(COMMENT_RE, '');
|
|
85
|
+
const lines = noComments.split('\n');
|
|
86
|
+
const headings = [];
|
|
87
|
+
let h1 = null;
|
|
88
|
+
let intro = 0;
|
|
89
|
+
const stack = [];
|
|
90
|
+
// CJK glyphs carry ~3x the visual weight of a latin char per cell — count
|
|
91
|
+
// weight, not raw chars, so a terse Chinese intro is not flagged empty.
|
|
92
|
+
const weight = (s) => {
|
|
93
|
+
const stripped = s.replace(/\s/g, '');
|
|
94
|
+
const cjk = (stripped.match(CJK_WEIGHT_RE) ?? []).length;
|
|
95
|
+
return cjk * 3 + (stripped.length - cjk);
|
|
96
|
+
};
|
|
97
|
+
const closeTo = (level) => {
|
|
98
|
+
const closing = [];
|
|
99
|
+
for (;;) {
|
|
100
|
+
const s = stack[stack.length - 1];
|
|
101
|
+
if (s === undefined || s.level < level)
|
|
102
|
+
break;
|
|
103
|
+
stack.pop();
|
|
104
|
+
closing.push({ level: s.level, heading: s.heading, bodyChars: s.subtree, directChars: s.direct });
|
|
105
|
+
}
|
|
106
|
+
// inner-first pop order must be flipped back to document order
|
|
107
|
+
headings.push(...closing.reverse());
|
|
108
|
+
};
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const m = HEADING_RE.exec(line);
|
|
111
|
+
if (m !== null) {
|
|
112
|
+
const level = m[1].length;
|
|
113
|
+
closeTo(level);
|
|
114
|
+
if (level === 1 && h1 === null)
|
|
115
|
+
h1 = m[2].trim();
|
|
116
|
+
stack.push({ level, heading: m[2].trim(), direct: 0, subtree: 0 });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (STATUS_BOILERPLATE_RE.test(line))
|
|
120
|
+
continue;
|
|
121
|
+
const w = weight(line);
|
|
122
|
+
if (stack.length === 0) {
|
|
123
|
+
intro += w;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const s of stack)
|
|
127
|
+
s.subtree += w;
|
|
128
|
+
stack[stack.length - 1].direct += w;
|
|
129
|
+
}
|
|
130
|
+
closeTo(1);
|
|
131
|
+
// The intro is the H1 section's DIRECT span (real pages all open with
|
|
132
|
+
// `# 标题`); the pre-heading accumulator only carries weight on heading-less bodies.
|
|
133
|
+
const firstH1 = headings.find((h) => h.level === 1);
|
|
134
|
+
const introWeight = firstH1 !== undefined ? firstH1.directChars : intro;
|
|
135
|
+
return { headings, introEmpty: introWeight < 40, h1 };
|
|
136
|
+
}
|
|
137
|
+
/** Section headings (##+) whose SUBTREE holds no content. A parent that groups
|
|
138
|
+
* non-empty subsections is an outline container, not an unfinished product;
|
|
139
|
+
* only wholly-empty subtrees indicate an unfilled skeleton section. */
|
|
140
|
+
export function emptySectionsOf(headings) {
|
|
141
|
+
return headings.filter((h) => h.level >= 2 && h.bodyChars === 0).map((h) => h.heading);
|
|
142
|
+
}
|
|
143
|
+
// --- links -----------------------------------------------------------------------
|
|
144
|
+
const LINK_MD_RE = /\]\(([^)\s]+)[^)]*\)/g;
|
|
145
|
+
const LINK_HREF_RE = /href="([^"]+)"/gi;
|
|
146
|
+
/** Extract internal page links (relative wiki paths + same-host absolute URLs). */
|
|
147
|
+
export function extractLinks(masked, opts) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const host = opts.baseUrl.replace(/\/+$/, '');
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
const push = (raw) => {
|
|
152
|
+
let target = raw.trim();
|
|
153
|
+
if (target === '' || target.startsWith('#')) {
|
|
154
|
+
if (target.startsWith('#'))
|
|
155
|
+
out.push({ path: target.slice(1), locale: 'en', anchor: true });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (/^(mailto:|javascript:)/i.test(target))
|
|
159
|
+
return;
|
|
160
|
+
let locale = 'en';
|
|
161
|
+
if (/^https?:\/\//i.test(target)) {
|
|
162
|
+
if (!target.toLowerCase().startsWith(host.toLowerCase()))
|
|
163
|
+
return; // external
|
|
164
|
+
target = target.slice(host.length);
|
|
165
|
+
}
|
|
166
|
+
if (/\.(png|jpe?g|gif|svg|webp|pdf|zip|css|js)\b/i.test(target))
|
|
167
|
+
return;
|
|
168
|
+
target = target.split('#')[0];
|
|
169
|
+
target = target.split('?')[0];
|
|
170
|
+
target = target.replace(/^\/+/, '');
|
|
171
|
+
if (target === '')
|
|
172
|
+
return;
|
|
173
|
+
if (target.startsWith('zh/')) {
|
|
174
|
+
locale = 'zh';
|
|
175
|
+
target = target.slice(3);
|
|
176
|
+
}
|
|
177
|
+
else if (target.startsWith('en/')) {
|
|
178
|
+
target = target.slice(3);
|
|
179
|
+
}
|
|
180
|
+
const key = `${locale}\u0000${target}`;
|
|
181
|
+
if (seen.has(key))
|
|
182
|
+
return;
|
|
183
|
+
seen.add(key);
|
|
184
|
+
out.push({ path: target, locale, anchor: false });
|
|
185
|
+
};
|
|
186
|
+
for (const re of [LINK_MD_RE, LINK_HREF_RE]) {
|
|
187
|
+
re.lastIndex = 0;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = re.exec(masked)) !== null)
|
|
190
|
+
push(m[1] ?? '');
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
// --- claims + stamps + language -----------------------------------------------------
|
|
195
|
+
const STAMP_RE = /上次核实|上次验证|last verified/i;
|
|
196
|
+
const PORT_RE = /:\d{4,5}\b/g;
|
|
197
|
+
const FS_PATH_RE = /\/(?:opt|var|etc|usr|srv)\/[\w./-]+/g;
|
|
198
|
+
const CMD_RE = /\b(?:systemctl|journalctl|docker|curl|nc|wget|iptables|nginx|caddy)\b/gi;
|
|
199
|
+
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
|
|
200
|
+
const CJK_WEIGHT_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
|
|
201
|
+
function distinct(text, re) {
|
|
202
|
+
return new Set(text.match(re) ?? []).size;
|
|
203
|
+
}
|
|
204
|
+
// --- main entry ---------------------------------------------------------------------
|
|
205
|
+
export function lintBody(body, opts) {
|
|
206
|
+
const masked = maskFences(body);
|
|
207
|
+
const visible = masked.replace(COMMENT_RE, '');
|
|
208
|
+
const target = redirectTarget(masked);
|
|
209
|
+
const isRedirectStub = /^\s*>\s*Redirect:/im.test(masked);
|
|
210
|
+
const { headings, introEmpty, h1 } = parseStructure(masked);
|
|
211
|
+
const links = extractLinks(visible, { baseUrl: opts.baseUrl }).filter((l) => !l.anchor);
|
|
212
|
+
const nonSpace = (visible.match(/\S/g) ?? []).length;
|
|
213
|
+
const cjk = (visible.match(CJK_RE) ?? []).length;
|
|
214
|
+
const claims = {
|
|
215
|
+
ports: distinct(visible, PORT_RE),
|
|
216
|
+
paths: distinct(visible, FS_PATH_RE),
|
|
217
|
+
commands: distinct(visible, CMD_RE),
|
|
218
|
+
};
|
|
219
|
+
const stateInfo = parseState(visible);
|
|
220
|
+
return {
|
|
221
|
+
isRedirectStub,
|
|
222
|
+
redirectTarget: target,
|
|
223
|
+
stubHasLink: !isRedirectStub || hasClickableExit(links.length),
|
|
224
|
+
hasStateBlock: stateInfo.has,
|
|
225
|
+
state: stateInfo.state,
|
|
226
|
+
todoMarkers: countTodoMarkers(masked),
|
|
227
|
+
emptySections: emptySectionsOf(headings),
|
|
228
|
+
introEmpty,
|
|
229
|
+
hasRelatedPages: /^#{1,6}\s+.*(相关页面|Related Pages)/im.test(masked),
|
|
230
|
+
h1,
|
|
231
|
+
headings,
|
|
232
|
+
links,
|
|
233
|
+
hasStamp: STAMP_RE.test(visible),
|
|
234
|
+
claims,
|
|
235
|
+
claimTotal: claims.ports + claims.paths + claims.commands,
|
|
236
|
+
cjkRatio: nonSpace === 0 ? 0 : cjk / nonSpace,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
|
|
240
|
+
export function h1TitleMismatch(lint, title) {
|
|
241
|
+
if (title === undefined || lint.h1 === null)
|
|
242
|
+
return false;
|
|
243
|
+
return lint.h1.trim().toLowerCase() !== title.trim().toLowerCase();
|
|
244
|
+
}
|
|
245
|
+
/** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
|
|
246
|
+
export function publishGateViolations(lint) {
|
|
247
|
+
const out = [];
|
|
248
|
+
if (lint.isRedirectStub && !lint.stubHasLink)
|
|
249
|
+
out.push('redirect-stub-no-exit');
|
|
250
|
+
if (lint.state === 'active' && (lint.todoMarkers > 0 || lint.emptySections.length > 0)) {
|
|
251
|
+
out.push('active-with-unfinished-skeleton');
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
@@ -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;
|
package/dist/surface.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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**:
|
|
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
|
|
package/dist/tools/create.js
CHANGED
|
@@ -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,
|
package/dist/tools/local.js
CHANGED
|
@@ -9,6 +9,7 @@ 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';
|
|
14
15
|
import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
|
|
@@ -59,7 +60,7 @@ const MAP_ARGS = {
|
|
|
59
60
|
action: s.enum(['show', 'refresh', 'timeline', 'maintain']).default('show'),
|
|
60
61
|
days: s.number().int().positive().optional().describe('timeline: keep only rows updated within the last N days'),
|
|
61
62
|
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
|
|
63
|
+
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
64
|
};
|
|
64
65
|
const MapArgsSchema = s.object(MAP_ARGS);
|
|
65
66
|
/** maintain: light tier is map rows + ONE read-only pages.list pass per locale
|
|
@@ -69,34 +70,55 @@ const MapArgsSchema = s.object(MAP_ARGS);
|
|
|
69
70
|
async function runMaintain(deps, mapDeps, snapshot, deep) {
|
|
70
71
|
const client = deps.getClient();
|
|
71
72
|
const tagIndex = new Map();
|
|
73
|
+
const liveInventory = [];
|
|
72
74
|
const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
|
|
73
75
|
for (const locale of locales) {
|
|
74
76
|
for (const item of await listPages(client, { locale })) {
|
|
75
77
|
tagIndex.set(`${item.locale}\u0000${item.path}`, item.tags);
|
|
78
|
+
liveInventory.push({ path: item.path, locale: item.locale, isPublished: item.isPublished });
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
const rows = snapshot.rows.map((r) => ({ ...r, tags: tagIndex.get(`${r.locale}\u0000${r.path}`) ?? [] }));
|
|
82
|
+
const bodyCache = new Map();
|
|
79
83
|
const readBody = deep
|
|
80
|
-
?
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return null;
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
? (path, locale) => {
|
|
85
|
+
const key = `${locale}\u0000${path}`;
|
|
86
|
+
const hit = bodyCache.get(key);
|
|
87
|
+
if (hit !== undefined)
|
|
88
|
+
return hit;
|
|
89
|
+
const pending = (async () => {
|
|
90
|
+
try {
|
|
91
|
+
return (await readPage(client, path, locale))?.content ?? null;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Unreadable page (invalid path / transport) is a scan miss, not a report failure.
|
|
95
|
+
if (err instanceof PathValidationError)
|
|
96
|
+
return null;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
bodyCache.set(key, pending);
|
|
101
|
+
return pending;
|
|
90
102
|
}
|
|
91
103
|
: undefined;
|
|
92
104
|
const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
|
|
105
|
+
const surface = await buildSurfaceReport({
|
|
106
|
+
rows,
|
|
107
|
+
generatedAt: report.generatedAt,
|
|
108
|
+
baseUrl: deps.options.baseUrl,
|
|
109
|
+
liveInventory,
|
|
110
|
+
deep,
|
|
111
|
+
readBody,
|
|
112
|
+
});
|
|
93
113
|
return {
|
|
94
114
|
action: 'maintain',
|
|
115
|
+
schema: 'historian.maintain.v2',
|
|
95
116
|
deep: report.deep,
|
|
96
117
|
generatedAt: report.generatedAt,
|
|
97
118
|
rowCount: report.rowCount,
|
|
98
119
|
report,
|
|
99
|
-
|
|
120
|
+
surface,
|
|
121
|
+
markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
|
|
100
122
|
urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
|
|
101
123
|
};
|
|
102
124
|
}
|
|
@@ -109,8 +131,10 @@ export function makeMapTool(deps) {
|
|
|
109
131
|
`show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
|
|
110
132
|
`(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
|
|
111
133
|
`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,
|
|
113
|
-
`candidates, tag vocab, section distribution
|
|
134
|
+
`maintain runs the read-only curation sweep + surface report (twin gap, near-duplicate titles, staleness, ` +
|
|
135
|
+
`diffusion/orphan candidates, tag vocab, section distribution, map-vs-live coverage, nav hygiene; ` +
|
|
136
|
+
`deep:true adds per-body freshness, stub reachability, broken/stacked/index-less links, twin parity, ` +
|
|
137
|
+
`unfinished skeletons, claim ledgers) and ` +
|
|
114
138
|
`answers a markdown report with a stable-key JSON tail. ${URL_MANDATE}.`,
|
|
115
139
|
args: MAP_ARGS,
|
|
116
140
|
execute: async (raw) => {
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/shared.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { assertLocalePair, PathValidationError } from '../wiki/locale.js';
|
|
12
12
|
import { scoreChecklist } from '../migrate-score.js';
|
|
13
13
|
import { selfReviewChecklist } from '../templates/genres.js';
|
|
14
|
+
import { lintBody, publishGateViolations } from '../lint.js';
|
|
14
15
|
/** Engine deps for one operation; client resolved lazily at use time. */
|
|
15
16
|
export function pageDeps(deps) {
|
|
16
17
|
return { client: deps.getClient(), options: deps.options, translate: deps.translate };
|
|
@@ -83,6 +84,8 @@ function hintFor(errorKind) {
|
|
|
83
84
|
return 'The wiki endpoint is unreachable or misconfigured — check baseUrl and network.';
|
|
84
85
|
case 'GraphQLError':
|
|
85
86
|
return 'The wiki answered a GraphQL error — check the path/locale arguments.';
|
|
87
|
+
case 'PublishGateError':
|
|
88
|
+
return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL)。';
|
|
86
89
|
default:
|
|
87
90
|
return 'Inspect the message and retry.';
|
|
88
91
|
}
|
|
@@ -304,3 +307,23 @@ export function sectionRefusalJson(path, allowedSections) {
|
|
|
304
307
|
const violation = sectionGuard(path, allowedSections);
|
|
305
308
|
return violation === null ? null : errEnvelope(new ConfigError(violation));
|
|
306
309
|
}
|
|
310
|
+
// --- publish gate (V6.1, HANDOFF #5.1 / #6.1) --------------------------------
|
|
311
|
+
export class PublishGateError extends Error {
|
|
312
|
+
constructor(message) {
|
|
313
|
+
super(message);
|
|
314
|
+
this.name = 'PublishGateError';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Hard gate on front-tier writes: refuses the two shapes that shipped real
|
|
318
|
+
* incidents — a page claiming Active with TODO markers or empty skeleton
|
|
319
|
+
* sections, and a redirect stub whose body carries no clickable exit.
|
|
320
|
+
* `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
|
|
321
|
+
* sanctioned work-in-progress escape hatch. Null = proceed. */
|
|
322
|
+
export function publishGateRefusalJson(content, locale, path, baseUrl) {
|
|
323
|
+
if (path.startsWith('_sandbox/') || isInternalPath(path))
|
|
324
|
+
return null;
|
|
325
|
+
const violations = publishGateViolations(lintBody(content, { locale, baseUrl }));
|
|
326
|
+
if (violations.length === 0)
|
|
327
|
+
return null;
|
|
328
|
+
return errEnvelope(new PublishGateError(`publish-gate: ${violations.join('; ')} on '${path}' (${locale})`));
|
|
329
|
+
}
|
package/dist/tools/write.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { tool } from '@opencode-ai/plugin';
|
|
8
8
|
import { appendSection, createPage, updatePage, PageNotFoundError } from '../wiki/pages.js';
|
|
9
9
|
import { readPage } from '../wiki/pages.read.js';
|
|
10
|
-
import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
|
|
10
|
+
import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, publishGateRefusalJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
|
|
11
11
|
const s = tool.schema;
|
|
12
12
|
const UPDATE_ARGS = {
|
|
13
13
|
path: s.string(),
|
|
@@ -33,6 +33,11 @@ export function makeUpdateTool(deps) {
|
|
|
33
33
|
if (page === null) {
|
|
34
34
|
return errEnvelope(new PageNotFoundError(`page '${args.path}' (${args.locale}) does not exist`));
|
|
35
35
|
}
|
|
36
|
+
if (args.content !== undefined) {
|
|
37
|
+
const gate = publishGateRefusalJson(args.content, args.locale, page.path, deps.options.baseUrl);
|
|
38
|
+
if (gate !== null)
|
|
39
|
+
return gate;
|
|
40
|
+
}
|
|
36
41
|
const result = await updatePage(pageDeps(deps), page.id, {
|
|
37
42
|
title: args.title,
|
|
38
43
|
content: args.content,
|
package/package.json
CHANGED
|
@@ -200,7 +200,7 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
|
|
|
200
200
|
| `historian_translate_snippet` | 翻译片段 | `text`, `from`(en/zh), `to`(en/zh) |
|
|
201
201
|
| `historian_search` | 搜索页面 | `query`, `kind`(title/content), `tags?`(1-5 个), `tagsMode?`(all 缺省/any) |
|
|
202
202
|
| `historian_read` | 读取页面 | `path`, `locale` |
|
|
203
|
-
| `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en
|
|
203
|
+
| `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v2`) |
|
|
204
204
|
| `historian_migrate` | 迁移页面到规范 | `path`, `genre?`, `apply`(false/true) |
|
|
205
205
|
| `historian_delete` | 删除页面 | `path`, `locale`, `confirm`(必须 "yes") |
|
|
206
206
|
| `historian_move` | 移动页面 | `path`, `locale`, `newPath`, `newLocale?`, `confirm`(必须 "yes") |
|
|
@@ -327,6 +327,8 @@ historian_map action:'refresh'
|
|
|
327
327
|
- **light 扫:每次批量写后必跑**——只基于地图行 + 每 locale 一次只读 `pages.list`(便宜,随批走)
|
|
328
328
|
- **deep 扫:每周至多一次**——逐页读正文,跑新鲜度(缺「上次核实于」/ 复核过期)与 `> Redirect:` 存根计数(贵,克制用)
|
|
329
329
|
|
|
330
|
+
light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(`_*` 机器命名空间暴露于侧栏 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
|
|
331
|
+
|
|
330
332
|
报告行 → 处置映射表:
|
|
331
333
|
|
|
332
334
|
| 报告行 | 含义 | 处置 |
|
|
@@ -338,6 +340,16 @@ historian_map action:'refresh'
|
|
|
338
340
|
| `tags.vocabulary` | 标签漂移 | 词表映射:近义标签收敛到主词,`historian_page_update` 批量改 |
|
|
339
341
|
| `redirects.stubs`(deep) | 重定向存根清单 | 核对目标存在、入链已改写;死链存根即修 |
|
|
340
342
|
| `freshness`(deep) | 缺核实戳 / reviewBy 过期 | 回 G5 卡补核;到期页列入下周复核 |
|
|
343
|
+
| `coverage.missingFromMap`(surface) | 新页/迁移未进地图 | `action:'refresh'` 后重扫 |
|
|
344
|
+
| `nav.machineSections`(surface) | `_*` 机器命名空间进侧栏 | 导航树手工策划,只挂主题章节 |
|
|
345
|
+
| `nav.sectionLandingMissing`(surface) | 章节缺落地页(面包屑 404) | 建章节总览页并链入 wiki-index |
|
|
346
|
+
| `unfinished`(surface deep) | Active 页含 TODO/空节/导言空 | 补全或降回 draft |
|
|
347
|
+
| `stubs` / `links.broken` / `toStubs` / `sameTargetStacks`(deep) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |
|
|
348
|
+
| `orphanPages` / `indexMissing`(deep) | 无入链 / 未入索引 | 归架:从相关页与 wiki-index 补链 |
|
|
349
|
+
| `roleDivergence`(deep) | 同题页 en/zh 一存根一活页 | 双侧收敛到同一权威页 |
|
|
350
|
+
| `twinParity`(deep) | 孪生正文分叉(长度/节结构) | 重译或重排落后的孪生腿 |
|
|
351
|
+
| `zhEnglishDominant`(deep) | zh 页英文为主(违反中文为主) | 按 zh-first 政策重写 |
|
|
352
|
+
| `ledgerClaims`(deep) | 事实密集页缺「上次核实于」戳 | 逐条对机器核实后补戳,或标 stale |
|
|
341
353
|
|
|
342
354
|
### 闸门回路 (gate):reading loop + sections guard
|
|
343
355
|
|
|
@@ -350,6 +362,14 @@ historian_map action:'refresh'
|
|
|
350
362
|
- 注入语义为**单块追加**:advisory 拼接到 system 提示的最后一个块(`\n\n` 分隔),system 为空数组时才新建块——绝不产生第二条 system 消息。严格 OpenAI 兼容后端(如 vLLM)会以 `System message must be at the beginning.` 拒绝多 system 请求,单块追加从根上规避此坑。
|
|
351
363
|
- 幂等去重:同一请求的任一 system 块已含 `historian_search` 字样则跳过注入。
|
|
352
364
|
|
|
365
|
+
#### 发布闸门 publish gate(写入硬拒,v0.5.0)
|
|
366
|
+
|
|
367
|
+
`historian_page_create` 与带 `content` 的 `historian_page_update` 在**任何写入前**硬检正文,违例即零写入拒绝(`errorKind: PublishGateError`):
|
|
368
|
+
|
|
369
|
+
- **R1 存根须有出口**:`> Redirect:` 开头的正文必须含 ≥1 条可点击链接;纯行内代码路径不算出口
|
|
370
|
+
- **R2 Active 不许半成品**:状态行为 `Active` 且正文含 TODO/TBD/占位注释或空节 → 拒绝;未完稿保持 `draft`——G1-G6 骨架状态行缺省即 `draft`,翻 Active 就是过闸动作
|
|
371
|
+
- 豁免:`_sandbox/**` 与内部层(`_meta/`、`_evidence/`);`append` 不过闸(增量语义),由 deep 扫描兜底
|
|
372
|
+
|
|
353
373
|
#### sections guard(路径闸门)
|
|
354
374
|
|
|
355
375
|
写入路径首段受插件选项 `sections` 白名单强制(配置后不在白名单的前缀被拒)。豁免段恒可写:`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence`——机构记忆不能反锁落地的着陆页与机器命名空间。`tier:"evidence"` 的页面不校验(证据层是原材料归宿)。白名单为空的部署不做前缀限制,实际权限由 wiki.js token 的 page rules 决定。
|