opencode-wiki-historian 0.5.0 → 0.5.2
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 +19 -2
- package/dist/lint.js +56 -10
- package/dist/maintain.d.ts +24 -2
- package/dist/maintain.js +164 -7
- package/dist/surface.d.ts +20 -2
- package/dist/surface.js +53 -7
- package/dist/templates/skeletons.js +2 -2
- package/dist/tools/local.js +34 -4
- package/dist/tools/shared.d.ts +3 -2
- package/dist/tools/shared.js +4 -3
- package/dist/wiki/nav.d.ts +21 -0
- package/dist/wiki/nav.js +43 -0
- package/package.json +1 -1
- package/skills/historian/SKILL.md +5 -5
package/dist/lint.d.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Locale } from './wiki/pages.read.js';
|
|
11
11
|
/** Machine rule keys; the human report renders zh labels from these. */
|
|
12
|
-
export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton';
|
|
12
|
+
export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton' | 'status-token-conflict';
|
|
13
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';
|
|
14
|
+
readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp' | 'status-token-conflict';
|
|
15
15
|
readonly detail: string;
|
|
16
16
|
}
|
|
17
17
|
export interface PageLink {
|
|
@@ -32,6 +32,15 @@ export interface BodyLint {
|
|
|
32
32
|
readonly stubHasLink: boolean;
|
|
33
33
|
readonly hasStateBlock: boolean;
|
|
34
34
|
readonly state: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
|
|
35
|
+
/** First state token parsed from a `| Status | X |` metadata table row
|
|
36
|
+
* (P1); null when the body carries no parseable table token. The colon
|
|
37
|
+
* header in `state` stays the AUTHORITY — this is the view, never a fix. */
|
|
38
|
+
readonly tableState: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
|
|
39
|
+
/** Every state token found in table rows, document order, deduped. */
|
|
40
|
+
readonly tableStates: readonly NonNullable<BodyLint['state']>[];
|
|
41
|
+
/** True when the header token and at least one table token disagree (P1:
|
|
42
|
+
* the machine-induced contradiction of live ids 9/92). */
|
|
43
|
+
readonly statusTokenConflict: boolean;
|
|
35
44
|
readonly todoMarkers: number;
|
|
36
45
|
/** Heading text of sections whose body (before the next heading ≤ level) is empty. */
|
|
37
46
|
readonly emptySections: readonly string[];
|
|
@@ -47,6 +56,14 @@ export interface BodyLint {
|
|
|
47
56
|
/** CJK chars / non-whitespace chars, zh body only meaningful; 0..1. */
|
|
48
57
|
readonly cjkRatio: number;
|
|
49
58
|
}
|
|
59
|
+
/** The one finding kind {@link statusTokenFindings} emits. */
|
|
60
|
+
export type StatusTokenFinding = LintFinding & {
|
|
61
|
+
readonly key: 'status-token-conflict';
|
|
62
|
+
};
|
|
63
|
+
/** Finding-key emission for status-token integrity (P1): both tokens present
|
|
64
|
+
* and disagreeing. The colon header remains the authority — this reports the
|
|
65
|
+
* contradiction, it never resolves it. */
|
|
66
|
+
export declare function statusTokenFindings(lint: BodyLint): readonly StatusTokenFinding[];
|
|
50
67
|
export interface HeadingSpan {
|
|
51
68
|
readonly level: number;
|
|
52
69
|
readonly heading: string;
|
package/dist/lint.js
CHANGED
|
@@ -47,20 +47,58 @@ function hasClickableExit(visibleNoAnchors) {
|
|
|
47
47
|
}
|
|
48
48
|
// --- status block -----------------------------------------------------------------
|
|
49
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
/** `| Status | X |` metadata-table row (P1). Line-anchored so only the FIRST
|
|
51
|
+
* cell is the label — column headers like `| Action | … | Status |` and
|
|
52
|
+
* separator rows never match. CJK-tolerant label (状态 / Status / 状态/Status
|
|
53
|
+
* in either order), optional bold. Run on fence-masked, comment-stripped
|
|
54
|
+
* text, so an injected fake token inside a code fence or HTML comment is
|
|
55
|
+
* inert. The value is only a token when classifyState() recognizes it —
|
|
56
|
+
* `| Status | — |` and free-form cells carry no token. */
|
|
57
|
+
const TABLE_STATE_RE = /^\s*\|\s*(?:\*\*)?\s*(?:状态|Status)(?:\s*\/\s*(?:状态|Status))?\s*(?:\*\*)?\s*\|\s*([^|\n]+?)\s*\|/i;
|
|
58
|
+
function classifyState(raw) {
|
|
59
|
+
const v = raw.trim().toLowerCase();
|
|
55
60
|
if (v.startsWith('active'))
|
|
56
|
-
return
|
|
61
|
+
return 'active';
|
|
57
62
|
if (v.startsWith('draft'))
|
|
58
|
-
return
|
|
63
|
+
return 'draft';
|
|
59
64
|
if (v.startsWith('superseded'))
|
|
60
|
-
return
|
|
65
|
+
return 'superseded';
|
|
61
66
|
if (v.startsWith('deprecated'))
|
|
62
|
-
return
|
|
63
|
-
return
|
|
67
|
+
return 'deprecated';
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function parseState(maskedNoComments) {
|
|
71
|
+
const m = STATE_LINE_RE.exec(maskedNoComments);
|
|
72
|
+
if (m === null)
|
|
73
|
+
return { has: false, state: null };
|
|
74
|
+
return { has: true, state: classifyState(m[1]) };
|
|
75
|
+
}
|
|
76
|
+
/** Deduped state tokens of every `| Status | X |` row, in document order. */
|
|
77
|
+
function parseTableStates(visible) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const line of visible.split('\n')) {
|
|
80
|
+
const m = TABLE_STATE_RE.exec(line);
|
|
81
|
+
if (m === null)
|
|
82
|
+
continue;
|
|
83
|
+
const st = classifyState(m[1]);
|
|
84
|
+
if (st !== null && !out.includes(st))
|
|
85
|
+
out.push(st);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** Finding-key emission for status-token integrity (P1): both tokens present
|
|
90
|
+
* and disagreeing. The colon header remains the authority — this reports the
|
|
91
|
+
* contradiction, it never resolves it. */
|
|
92
|
+
export function statusTokenFindings(lint) {
|
|
93
|
+
if (!lint.statusTokenConflict || lint.state === null)
|
|
94
|
+
return [];
|
|
95
|
+
const table = lint.tableStates.filter((s) => s !== lint.state).join(', ');
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
key: 'status-token-conflict',
|
|
99
|
+
detail: `colon header '${lint.state}' vs table row '${table}' on '${lint.h1 ?? '?'}'`,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
64
102
|
}
|
|
65
103
|
// --- markers, headings, intro -------------------------------------------------------
|
|
66
104
|
const TODO_COMMENT_RE = /(TODO|TBD|PLACEHOLDER|占位)/i;
|
|
@@ -217,12 +255,17 @@ export function lintBody(body, opts) {
|
|
|
217
255
|
commands: distinct(visible, CMD_RE),
|
|
218
256
|
};
|
|
219
257
|
const stateInfo = parseState(visible);
|
|
258
|
+
const tableStates = parseTableStates(visible);
|
|
259
|
+
const statusTokenConflict = stateInfo.state !== null && tableStates.some((s) => s !== stateInfo.state);
|
|
220
260
|
return {
|
|
221
261
|
isRedirectStub,
|
|
222
262
|
redirectTarget: target,
|
|
223
263
|
stubHasLink: !isRedirectStub || hasClickableExit(links.length),
|
|
224
264
|
hasStateBlock: stateInfo.has,
|
|
225
265
|
state: stateInfo.state,
|
|
266
|
+
tableState: tableStates.length > 0 ? tableStates[0] : null,
|
|
267
|
+
tableStates,
|
|
268
|
+
statusTokenConflict,
|
|
226
269
|
todoMarkers: countTodoMarkers(masked),
|
|
227
270
|
emptySections: emptySectionsOf(headings),
|
|
228
271
|
introEmpty,
|
|
@@ -250,5 +293,8 @@ export function publishGateViolations(lint) {
|
|
|
250
293
|
if (lint.state === 'active' && (lint.todoMarkers > 0 || lint.emptySections.length > 0)) {
|
|
251
294
|
out.push('active-with-unfinished-skeleton');
|
|
252
295
|
}
|
|
296
|
+
if (lint.state === 'active' && lint.statusTokenConflict) {
|
|
297
|
+
out.push('status-token-conflict');
|
|
298
|
+
}
|
|
253
299
|
return out;
|
|
254
300
|
}
|
package/dist/maintain.d.ts
CHANGED
|
@@ -64,6 +64,18 @@ export interface FreshnessScan {
|
|
|
64
64
|
readonly missingLastVerified: readonly MissingStamp[];
|
|
65
65
|
readonly expiredReviewBy: readonly ExpiredReview[];
|
|
66
66
|
}
|
|
67
|
+
/** One cadence-due page (advisory queue — maintain NEVER auto-writes the stamp). */
|
|
68
|
+
export interface DueForReviewRow {
|
|
69
|
+
readonly path: string;
|
|
70
|
+
readonly locale: Locale;
|
|
71
|
+
/** Whole days from the newest honest stamp date to now (floored; confessional-only
|
|
72
|
+
* stamps stamp their own date but never reset the clock — see dueForReviewOf). */
|
|
73
|
+
readonly stampAge: number;
|
|
74
|
+
/** Days: metadata 复核周期/Review cadence row when parseable, else genre default. */
|
|
75
|
+
readonly cadence: number;
|
|
76
|
+
/** Re-check commands parsed from the ledger's command column (empty when none). */
|
|
77
|
+
readonly verifyCommands: readonly string[];
|
|
78
|
+
}
|
|
67
79
|
export interface MaintainReport {
|
|
68
80
|
readonly schema: typeof MAINTAIN_SCHEMA;
|
|
69
81
|
readonly generatedAt: string;
|
|
@@ -91,7 +103,7 @@ export interface MaintainReport {
|
|
|
91
103
|
childPath: string;
|
|
92
104
|
}[];
|
|
93
105
|
};
|
|
94
|
-
readonly
|
|
106
|
+
readonly flatRootPages: readonly {
|
|
95
107
|
section: string;
|
|
96
108
|
paths: readonly string[];
|
|
97
109
|
}[];
|
|
@@ -113,10 +125,20 @@ export interface MaintainReport {
|
|
|
113
125
|
rows: number;
|
|
114
126
|
}[];
|
|
115
127
|
readonly freshness: FreshnessScan | null;
|
|
128
|
+
/** Deep-only advisory queue (null in light, like freshness). */
|
|
129
|
+
readonly dueForReview: readonly DueForReviewRow[] | null;
|
|
116
130
|
}
|
|
117
|
-
export declare const MAINTAIN_SCHEMA = "historian.maintain.
|
|
131
|
+
export declare const MAINTAIN_SCHEMA = "historian.maintain.v2";
|
|
118
132
|
/** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
|
|
119
133
|
export declare const DUP_TITLE_THRESHOLD = 0.75;
|
|
134
|
+
/** Stamp label incl. the 上次验证 variant (the set lint.ts STAMP_RE tests) — one
|
|
135
|
+
* line-level source of truth for the honesty rule shared by surface + freshness. */
|
|
136
|
+
export declare const STAMP_LINE_RE: RegExp;
|
|
137
|
+
/** A stamp that confesses the review never executed (swarm-A P2 "stamp honesty"):
|
|
138
|
+
* it must not exempt the claim ledger (surface) nor reset the cadence clock
|
|
139
|
+
* (dueForReview). Matched per stamp line, never page-wide — "baseline" is a
|
|
140
|
+
* common GPU-benchmark noun outside a stamp. */
|
|
141
|
+
export declare const CONFESSIONAL_STAMP_RE: RegExp;
|
|
120
142
|
export declare function buildMaintainReport(input: MaintainInput, opts?: MaintainOptions): Promise<MaintainReport>;
|
|
121
143
|
/** Human report + (always) a fenced machine-readable JSON block at the END —
|
|
122
144
|
* the same MaintainReport object the tool envelope carries. */
|
package/dist/maintain.js
CHANGED
|
@@ -17,7 +17,7 @@ import { classifyGenre } from './templates/genres.js';
|
|
|
17
17
|
import { isInternalPath } from './tools/shared.js';
|
|
18
18
|
import { normalize } from './migrate-score.js';
|
|
19
19
|
// --- Constants --------------------------------------------------------------
|
|
20
|
-
export const MAINTAIN_SCHEMA = 'historian.maintain.
|
|
20
|
+
export const MAINTAIN_SCHEMA = 'historian.maintain.v2';
|
|
21
21
|
/** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
|
|
22
22
|
export const DUP_TITLE_THRESHOLD = 0.75;
|
|
23
23
|
const DAY_MS = 86_400_000;
|
|
@@ -28,9 +28,34 @@ const DEFAULT_TOP_N = 10;
|
|
|
28
28
|
// pre-emptively so the deep sweep lights up when the genre lane lands.
|
|
29
29
|
const FRESHNESS_GENRES = ['G5', 'G6'];
|
|
30
30
|
const STAMP_RE = /上次核实|last verified/i;
|
|
31
|
+
/** Stamp label incl. the 上次验证 variant (the set lint.ts STAMP_RE tests) — one
|
|
32
|
+
* line-level source of truth for the honesty rule shared by surface + freshness. */
|
|
33
|
+
export const STAMP_LINE_RE = /上次核实|上次验证|last verified/i;
|
|
34
|
+
/** A stamp that confesses the review never executed (swarm-A P2 "stamp honesty"):
|
|
35
|
+
* it must not exempt the claim ledger (surface) nor reset the cadence clock
|
|
36
|
+
* (dueForReview). Matched per stamp line, never page-wide — "baseline" is a
|
|
37
|
+
* common GPU-benchmark noun outside a stamp. */
|
|
38
|
+
export const CONFESSIONAL_STAMP_RE = /(not re-run|未复跑|未复核|baseline)/i;
|
|
31
39
|
const REVIEW_LABEL_RE = /^(复核周期|复核期限|复核日期|review[-_ ]?by|review[-_ ]?due)$/i;
|
|
32
40
|
const ISO_DATE_RE = /\d{4}-\d{2}-\d{2}/;
|
|
33
41
|
const REDIRECT_RE = /^>\s*Redirect:/i;
|
|
42
|
+
/** Table column headers that carry the per-row last-verified date (G5 ledger card). */
|
|
43
|
+
const STAMP_COL_RE = /^(?:上次核实于?|上次验证于?|last verified)$/i;
|
|
44
|
+
/** Metadata row naming the review cadence — its value is a DURATION, unlike the
|
|
45
|
+
* ISO-date 复核期限/review-by row REVIEW_LABEL_RE owns. */
|
|
46
|
+
const CADENCE_LABEL_RE = /^(?:复核周期|复核节奏|review[-_ ]?cadence|cadence)$/i;
|
|
47
|
+
/** Ledger command-column headers: zh 复核命令/验证命令/命令/用法\/命令,
|
|
48
|
+
* en Re-check command / Verify command(s) / Command(s) / Usage \/ Command. */
|
|
49
|
+
const COMMAND_COL_RE = /^(?:命令|用法\s*\/\s*命令|复核命令|验证命令|commands?|usage\s*\/\s*commands?|re[-_ ]?check[-_ ]?commands?|verify[-_ ]?commands?)$/i;
|
|
50
|
+
const DASH_CELL_RE = /^:?-{2,}:?$/;
|
|
51
|
+
const NULLISH_CELL_RE = /^(?:—|–|-|n\/?a|待补充|todo|\?)$/i;
|
|
52
|
+
/** Cadence floors when a stamped page carries no parseable metadata row:
|
|
53
|
+
* G5 machine-state ledgers 7d (weekly re-run — the round-1 plan supersedes the
|
|
54
|
+
* older 30d skeleton hint), G4 concepts and G6 how-tos 90d (the G6 skeleton's
|
|
55
|
+
* own 复核周期 example "如每 90 天"); the remaining genres (G1/G2/G3) have no
|
|
56
|
+
* cadence guidance anywhere, so the conservative quarterly 90d default applies. */
|
|
57
|
+
const GENRE_CADENCE_DAYS = { G5: 7, G4: 90, G6: 90 };
|
|
58
|
+
const DEFAULT_CADENCE_DAYS = 90;
|
|
34
59
|
// --- Title similarity (trigram core copied from migrate-score.ts:289, where it
|
|
35
60
|
// --- is private with a 0.95 roundtrip bar; maintain needs its own threshold) ---
|
|
36
61
|
function titleGrams(s) {
|
|
@@ -58,8 +83,11 @@ function jaccard(a, b) {
|
|
|
58
83
|
function cmpStr(a, b) {
|
|
59
84
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
60
85
|
}
|
|
86
|
+
// bold-merge deliberately leaves (重定向)/(redirect)-suffixed stubs beside their
|
|
87
|
+
// live twins; clustering those pairs reports non-defects and buries true near-dupes.
|
|
88
|
+
const STUB_TITLE_RE = /[((]\s*(?:重定向|redirect)\s*[))]\s*$/i;
|
|
61
89
|
function findDuplicates(rows) {
|
|
62
|
-
const units = rows.map((r) => ({
|
|
90
|
+
const units = rows.filter((r) => !STUB_TITLE_RE.test(r.title)).map((r) => ({
|
|
63
91
|
path: r.path,
|
|
64
92
|
title: r.title,
|
|
65
93
|
grams: titleGrams(normalize(r.title).toLowerCase()),
|
|
@@ -147,7 +175,7 @@ function singleChildDirs(paths) {
|
|
|
147
175
|
}
|
|
148
176
|
return out;
|
|
149
177
|
}
|
|
150
|
-
function
|
|
178
|
+
function flatRootPages(paths) {
|
|
151
179
|
const bySection = new Map();
|
|
152
180
|
for (const path of paths) {
|
|
153
181
|
if (path.split('/').length !== 2)
|
|
@@ -201,6 +229,117 @@ function reviewByOf(body) {
|
|
|
201
229
|
}
|
|
202
230
|
return null;
|
|
203
231
|
}
|
|
232
|
+
// --- dueForReview (cadence × stamp join; advisory only) --------------------------
|
|
233
|
+
function tableCells(line) {
|
|
234
|
+
const t = line.trim();
|
|
235
|
+
return t.startsWith('|') ? t.split('|').map((c) => c.trim()) : null;
|
|
236
|
+
}
|
|
237
|
+
const isSeparatorRow = (cells) => cells.some((c) => c !== '' && DASH_CELL_RE.test(c));
|
|
238
|
+
/** Every (date, confesses-non-execution) attestation in a body, two real shapes:
|
|
239
|
+
* a stamp LABEL line carrying its ISO date (metadata rows, quote stamps, struck
|
|
240
|
+
* history — the cockpit / 09-08-sweep forms) and each date under a
|
|
241
|
+
* 上次核实于/Last verified table column (the G5 card's per-row ledger dates). */
|
|
242
|
+
function stampEntriesOf(body) {
|
|
243
|
+
const out = [];
|
|
244
|
+
let dateCol = -1;
|
|
245
|
+
for (const line of body.split('\n')) {
|
|
246
|
+
const lineDate = STAMP_LINE_RE.test(line) ? line.match(ISO_DATE_RE) : null;
|
|
247
|
+
if (lineDate !== null)
|
|
248
|
+
out.push({ ms: Date.parse(lineDate[0]), confessional: CONFESSIONAL_STAMP_RE.test(line) });
|
|
249
|
+
const cells = tableCells(line);
|
|
250
|
+
if (cells === null) {
|
|
251
|
+
dateCol = -1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (isSeparatorRow(cells))
|
|
255
|
+
continue;
|
|
256
|
+
// A `| 上次核实 | 2026-01-01 |` label row carries its own date (line rule above
|
|
257
|
+
// owns it) — only a date-less header row arms the column extraction, else the
|
|
258
|
+
// next value row (any label!) would be mis-read as a ledger date.
|
|
259
|
+
const h = ISO_DATE_RE.test(line) ? -1 : cells.findIndex((c) => STAMP_COL_RE.test(c));
|
|
260
|
+
if (h >= 0) {
|
|
261
|
+
dateCol = h;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const cell = dateCol >= 0 ? cells[dateCol] : undefined;
|
|
265
|
+
if (cell !== undefined) {
|
|
266
|
+
const m = cell.match(ISO_DATE_RE);
|
|
267
|
+
if (m !== null)
|
|
268
|
+
out.push({ ms: Date.parse(m[0]), confessional: CONFESSIONAL_STAMP_RE.test(cell) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out.filter((e) => !Number.isNaN(e.ms));
|
|
272
|
+
}
|
|
273
|
+
/** '30天' / '每 30 天' / 'every 30 days' / '7d' / '2 weeks' / bare '14' → days.
|
|
274
|
+
* Dates, TODOs and prose return null — no cadence is ever fabricated. */
|
|
275
|
+
function parseDays(text) {
|
|
276
|
+
const t = text.trim();
|
|
277
|
+
const w = t.match(/(\d{1,3})\s*(?:weeks?|wks?|w\b|周)/i);
|
|
278
|
+
if (w !== null)
|
|
279
|
+
return Number(w[1]) * 7;
|
|
280
|
+
const d = t.match(/(\d{1,4})\s*(?:days?|d\b|天|日)/i);
|
|
281
|
+
if (d !== null)
|
|
282
|
+
return Number(d[1]);
|
|
283
|
+
return /^\d{1,3}$/.test(t) ? Number(t) : null;
|
|
284
|
+
}
|
|
285
|
+
function cadenceDaysOf(body) {
|
|
286
|
+
for (const line of body.split('\n')) {
|
|
287
|
+
const cells = tableCells(line);
|
|
288
|
+
if (cells === null || cells.length < 3 || !CADENCE_LABEL_RE.test(cells[1] ?? ''))
|
|
289
|
+
continue;
|
|
290
|
+
for (const cell of cells.slice(2)) {
|
|
291
|
+
const days = parseDays(cell);
|
|
292
|
+
if (days !== null)
|
|
293
|
+
return days;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/** Re-check commands from ledger verification tables (see COMMAND_COL_RE):
|
|
299
|
+
* backticks stripped, empty/nullish cells skipped, order kept, duplicates dropped. */
|
|
300
|
+
function verifyCommandsOf(body) {
|
|
301
|
+
const out = [];
|
|
302
|
+
let cmdCol = -1;
|
|
303
|
+
for (const line of body.split('\n')) {
|
|
304
|
+
const cells = tableCells(line);
|
|
305
|
+
if (cells === null) {
|
|
306
|
+
cmdCol = -1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (isSeparatorRow(cells))
|
|
310
|
+
continue;
|
|
311
|
+
const h = cells.findIndex((c) => COMMAND_COL_RE.test(c));
|
|
312
|
+
if (h >= 0) {
|
|
313
|
+
cmdCol = h;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const cell = cmdCol >= 0 ? cells[cmdCol] : undefined;
|
|
317
|
+
if (cell === undefined)
|
|
318
|
+
continue;
|
|
319
|
+
const cmd = cell.replace(/^`([\s\S]*)`$/, '$1').trim();
|
|
320
|
+
if (cmd !== '' && !NULLISH_CELL_RE.test(cmd) && !out.includes(cmd))
|
|
321
|
+
out.push(cmd);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
/** The join (swarm-B P-dueForReview, stamp honesty as its immune system): due
|
|
326
|
+
* when the newest HONEST stamp is at or past the cadence (a weekly card is due
|
|
327
|
+
* again on day 7), or when every stamp confesses non-execution — a confession
|
|
328
|
+
* records intent, not verification, so it never exempts the page. A struck old
|
|
329
|
+
* confessional stamp beside a fresh honest one (supersede-keeping-struck-old)
|
|
330
|
+
* runs on the honest clock. */
|
|
331
|
+
function dueForReviewOf(body, genre, now) {
|
|
332
|
+
const entries = stampEntriesOf(body);
|
|
333
|
+
if (entries.length === 0)
|
|
334
|
+
return null;
|
|
335
|
+
const honest = entries.filter((e) => !e.confessional);
|
|
336
|
+
const clock = honest.length > 0 ? honest : entries;
|
|
337
|
+
const stampAge = Math.floor((now.getTime() - Math.max(...clock.map((e) => e.ms))) / DAY_MS);
|
|
338
|
+
const cadence = cadenceDaysOf(body) ?? GENRE_CADENCE_DAYS[genre] ?? DEFAULT_CADENCE_DAYS;
|
|
339
|
+
if (honest.length > 0 && stampAge < cadence)
|
|
340
|
+
return null;
|
|
341
|
+
return { stampAge, cadence, verifyCommands: verifyCommandsOf(body) };
|
|
342
|
+
}
|
|
204
343
|
// --- buildMaintainReport --------------------------------------------------------
|
|
205
344
|
export async function buildMaintainReport(input, opts = {}) {
|
|
206
345
|
const now = opts.now ?? new Date();
|
|
@@ -219,11 +358,13 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
219
358
|
localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
|
|
220
359
|
let redirects = { available: false, count: 0, stubs: [] };
|
|
221
360
|
let freshness = null;
|
|
361
|
+
let dueForReview = null;
|
|
222
362
|
if (deep && opts.readBody !== undefined) {
|
|
223
363
|
const readBody = opts.readBody;
|
|
224
364
|
const stubs = [];
|
|
225
365
|
const missing = [];
|
|
226
366
|
const expired = [];
|
|
367
|
+
const due = [];
|
|
227
368
|
let scanned = 0;
|
|
228
369
|
let unreadable = 0;
|
|
229
370
|
for (const r of kept) {
|
|
@@ -239,6 +380,9 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
239
380
|
continue; // stubs are pointers — exempt from the freshness-stamp rule
|
|
240
381
|
}
|
|
241
382
|
const genre = classifyGenre({ title: r.title, body }).genre;
|
|
383
|
+
const dfr = dueForReviewOf(body, genre, now);
|
|
384
|
+
if (dfr !== null)
|
|
385
|
+
due.push({ path: r.path, locale: r.locale, ...dfr });
|
|
242
386
|
if (!FRESHNESS_GENRES.includes(genre))
|
|
243
387
|
continue;
|
|
244
388
|
if (!STAMP_RE.test(body))
|
|
@@ -253,6 +397,7 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
253
397
|
}
|
|
254
398
|
redirects = { available: true, count: stubs.length, stubs };
|
|
255
399
|
freshness = { scanned, unreadable, missingLastVerified: missing, expiredReviewBy: expired };
|
|
400
|
+
dueForReview = due.sort((a, b) => b.stampAge - a.stampAge || cmpStr(a.path, b.path) || cmpStr(a.locale, b.locale));
|
|
256
401
|
}
|
|
257
402
|
return {
|
|
258
403
|
schema: MAINTAIN_SCHEMA,
|
|
@@ -270,11 +415,12 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
270
415
|
duplicates: { threshold: DUP_TITLE_THRESHOLD, clusters: findDuplicates(kept) },
|
|
271
416
|
staleness: { topN, oldest: staleness(kept, now, topN) },
|
|
272
417
|
diffusion: { singleChildDirs: singleChildDirs(paths) },
|
|
273
|
-
|
|
418
|
+
flatRootPages: flatRootPages(paths),
|
|
274
419
|
tags: tagVocab(kept),
|
|
275
420
|
redirects,
|
|
276
421
|
sections: sectionDist(kept),
|
|
277
422
|
freshness,
|
|
423
|
+
dueForReview,
|
|
278
424
|
};
|
|
279
425
|
}
|
|
280
426
|
// --- renderMaintainMarkdown -----------------------------------------------------
|
|
@@ -313,10 +459,10 @@ export function renderMaintainMarkdown(r) {
|
|
|
313
459
|
L.push(' - none');
|
|
314
460
|
for (const d of r.diffusion.singleChildDirs)
|
|
315
461
|
L.push(` - \`${d.dir}/\` holds only \`${d.childPath}\``);
|
|
316
|
-
L.push('- root
|
|
317
|
-
if (r.
|
|
462
|
+
L.push('- flat root pages per section (depth-2 listing, shelving hint — NOT inbound analysis; true orphans = surface deep links.orphanPages):');
|
|
463
|
+
if (r.flatRootPages.length === 0)
|
|
318
464
|
L.push(' - none');
|
|
319
|
-
for (const o of r.
|
|
465
|
+
for (const o of r.flatRootPages)
|
|
320
466
|
L.push(` - \`${o.section}/\` (${fmt(o.paths.length)}): ${o.paths.map((p) => `\`${p}\``).join(', ')}`);
|
|
321
467
|
L.push('', '## Tag vocabulary', '');
|
|
322
468
|
if (!r.tags.available)
|
|
@@ -347,6 +493,17 @@ export function renderMaintainMarkdown(r) {
|
|
|
347
493
|
for (const e of r.freshness.expiredReviewBy)
|
|
348
494
|
L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
|
|
349
495
|
}
|
|
496
|
+
if (r.dueForReview !== null) {
|
|
497
|
+
L.push('', `## 到期复核 Due for review(deep,advisory)`, '');
|
|
498
|
+
L.push(`- ${fmt(r.dueForReview.length)} 条 —仅提示,机器不代写;复核由人或复核协议执行`);
|
|
499
|
+
for (const d of r.dueForReview) {
|
|
500
|
+
const why = d.stampAge >= d.cadence
|
|
501
|
+
? `戳龄 ${fmt(d.stampAge)}d ≥ 周期 ${fmt(d.cadence)}d`
|
|
502
|
+
: `confessional stamp(自称未复跑),周期 ${fmt(d.cadence)}d 未到亦列`;
|
|
503
|
+
const cmds = d.verifyCommands.length > 0 ? ` · ${d.verifyCommands.map((c) => '`' + c + '`').join(' ')}` : '';
|
|
504
|
+
L.push(` - \`${d.locale}/${d.path}\` — ${why}${cmds}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
350
507
|
L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
|
|
351
508
|
return L.join('\n');
|
|
352
509
|
}
|
package/dist/surface.d.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* tier costs nothing beyond the map baseline, the deep tier reads every body
|
|
12
12
|
* once (caller pools the reads).
|
|
13
13
|
*/
|
|
14
|
-
import type
|
|
14
|
+
import { type MaintainRow } from './maintain.js';
|
|
15
15
|
import type { Locale } from './wiki/pages.read.js';
|
|
16
|
+
import type { NavSnapshot } from './wiki/nav.js';
|
|
16
17
|
export declare const SURFACE_SCHEMA: "historian.surface.v1";
|
|
17
18
|
/** Minimal shape of a live `pages.list` row the surface diff needs. */
|
|
18
19
|
export interface LiveRow {
|
|
@@ -26,6 +27,12 @@ export interface SurfaceInput {
|
|
|
26
27
|
readonly generatedAt: string;
|
|
27
28
|
readonly baseUrl: string;
|
|
28
29
|
readonly liveInventory?: readonly LiveRow[];
|
|
30
|
+
/**
|
|
31
|
+
* Live primary nav. Issue #1's disease is sidebar exposure, so the check
|
|
32
|
+
* must read the real tree — omit/null reports `available: false` rather
|
|
33
|
+
* than falsely claiming a clean nav.
|
|
34
|
+
*/
|
|
35
|
+
readonly nav?: NavSnapshot | null;
|
|
29
36
|
readonly deep?: boolean;
|
|
30
37
|
readonly readBody?: (path: string, locale: Locale) => Promise<string | null>;
|
|
31
38
|
}
|
|
@@ -39,7 +46,18 @@ export interface SurfaceCoverage {
|
|
|
39
46
|
readonly removedFromLive: number;
|
|
40
47
|
}
|
|
41
48
|
export interface SurfaceNav {
|
|
42
|
-
readonly
|
|
49
|
+
readonly available: boolean;
|
|
50
|
+
readonly mode: string | null;
|
|
51
|
+
/** DYNAMIC/MIXED re-mirror the filesystem page tree into the sidebar — the exact Issue #1 relapse. */
|
|
52
|
+
readonly filesystemExposed: boolean;
|
|
53
|
+
/** Underscore-prefixed (machine-namespace) links explicitly mounted in the curated tree. */
|
|
54
|
+
readonly machineLinks: {
|
|
55
|
+
readonly locale: string;
|
|
56
|
+
readonly label: string;
|
|
57
|
+
readonly target: string;
|
|
58
|
+
}[];
|
|
59
|
+
/** Informational: underscore segments in the PAGE tree — by design (_meta/_evidence/_sandbox/_data). */
|
|
60
|
+
readonly machinePaths: readonly string[];
|
|
43
61
|
readonly sectionLandingMissing: {
|
|
44
62
|
readonly dir: string;
|
|
45
63
|
readonly pagePaths: number;
|
package/dist/surface.js
CHANGED
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
import { isInternalPath } from './tools/shared.js';
|
|
15
15
|
import { lintBody } from './lint.js';
|
|
16
16
|
import { classifyGenre } from './templates/genres.js';
|
|
17
|
+
import { CONFESSIONAL_STAMP_RE, STAMP_LINE_RE } from './maintain.js';
|
|
17
18
|
export const SURFACE_SCHEMA = 'historian.surface.v1';
|
|
18
19
|
const MACHINE_SEG_RE = /^_/;
|
|
20
|
+
// nav targets carry a leading slash and may pre-pend the locale segment
|
|
21
|
+
// (/zh/_meta/x) — machine check runs on the first real path segment.
|
|
22
|
+
const MACHINE_TARGET_RE = /^\/(?:(?:en|zh)\/)?_[^/]+/;
|
|
19
23
|
const ROOT_EXEMPT = new Set(['home', 'wiki-index']);
|
|
20
24
|
const visible = (r) => r.isPublished !== false && r.isPrivate !== true;
|
|
21
25
|
const rowKey = (path, locale) => `${locale}\u0000${path}`;
|
|
@@ -23,15 +27,24 @@ function isFrontPath(path) {
|
|
|
23
27
|
return !isInternalPath(path) && !path.startsWith('_sandbox/') && !path.startsWith('_data/');
|
|
24
28
|
}
|
|
25
29
|
// --- light tier ---------------------------------------------------------------
|
|
26
|
-
function buildNav(rows, live) {
|
|
30
|
+
function buildNav(rows, live, nav) {
|
|
27
31
|
const paths = new Set();
|
|
28
32
|
for (const r of rows)
|
|
29
33
|
paths.add(r.path);
|
|
30
34
|
for (const r of live ?? [])
|
|
31
35
|
paths.add(r.path);
|
|
32
|
-
const
|
|
36
|
+
const machinePaths = [...new Set([...paths].map((p) => p.split('/')[0]))]
|
|
33
37
|
.filter((s) => MACHINE_SEG_RE.test(s))
|
|
34
38
|
.sort();
|
|
39
|
+
const mode = nav?.mode ?? null;
|
|
40
|
+
const machineLinks = [];
|
|
41
|
+
for (const t of nav?.trees ?? []) {
|
|
42
|
+
for (const it of t.items) {
|
|
43
|
+
if (MACHINE_TARGET_RE.test(it.target)) {
|
|
44
|
+
machineLinks.push({ locale: t.locale, label: it.label, target: it.target });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
35
48
|
const perDir = new Map();
|
|
36
49
|
for (const p of paths) {
|
|
37
50
|
const seg = p.split('/');
|
|
@@ -43,7 +56,14 @@ function buildNav(rows, live) {
|
|
|
43
56
|
.filter(([dir, n]) => n >= 2 && !paths.has(dir))
|
|
44
57
|
.map(([dir, pagePaths]) => ({ dir, pagePaths }))
|
|
45
58
|
.sort((a, b) => b.pagePaths - a.pagePaths || a.dir.localeCompare(b.dir));
|
|
46
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
available: nav != null,
|
|
61
|
+
mode,
|
|
62
|
+
filesystemExposed: mode === 'DYNAMIC' || mode === 'MIXED',
|
|
63
|
+
machineLinks,
|
|
64
|
+
machinePaths,
|
|
65
|
+
sectionLandingMissing,
|
|
66
|
+
};
|
|
47
67
|
}
|
|
48
68
|
function buildCoverage(rows, live) {
|
|
49
69
|
if (live === undefined)
|
|
@@ -92,6 +112,13 @@ function resolveTargetPath(baseUrl, rawHref) {
|
|
|
92
112
|
return { path: t.slice(3), locale: 'en' };
|
|
93
113
|
return { path: t, locale: 'en' };
|
|
94
114
|
}
|
|
115
|
+
/** True when EVERY stamp-bearing line confesses non-execution — see the
|
|
116
|
+
* ledgerClaims rule in buildDeep. Lines are the unit of judgement because a
|
|
117
|
+
* struck old stamp beside a fresh honest one must keep the exemption. */
|
|
118
|
+
function isConfessionalStamp(body) {
|
|
119
|
+
const stampLines = body.split('\n').filter((l) => STAMP_LINE_RE.test(l));
|
|
120
|
+
return stampLines.length > 0 && stampLines.every((l) => CONFESSIONAL_STAMP_RE.test(l));
|
|
121
|
+
}
|
|
95
122
|
async function scanBodies(input) {
|
|
96
123
|
if (input.readBody === undefined)
|
|
97
124
|
return [];
|
|
@@ -233,7 +260,13 @@ function buildDeep(input, scans) {
|
|
|
233
260
|
.map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
|
|
234
261
|
const ledgerClaims = [];
|
|
235
262
|
for (const s of ok) {
|
|
236
|
-
|
|
263
|
+
// Stamp honesty (swarm-A P2): a stamp that only ever confesses the review
|
|
264
|
+
// never ran ("…(content not re-run)"/未复跑/未复核/baseline) is not evidence —
|
|
265
|
+
// claiming review without execution must not suppress the claim ledger.
|
|
266
|
+
// Any single non-confessional stamp line (incl. supersede-keeping-struck-old)
|
|
267
|
+
// exempts exactly as before.
|
|
268
|
+
const exempt = s.lint.hasStamp && !isConfessionalStamp(s.body);
|
|
269
|
+
if (s.lint.isRedirectStub || !isFrontPath(s.row.path) || exempt)
|
|
237
270
|
continue;
|
|
238
271
|
const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
|
|
239
272
|
if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
|
|
@@ -269,7 +302,7 @@ export async function buildSurfaceReport(input) {
|
|
|
269
302
|
generatedAt: input.generatedAt,
|
|
270
303
|
deep: input.deep === true,
|
|
271
304
|
coverage: buildCoverage(input.rows, input.liveInventory),
|
|
272
|
-
nav: buildNav(input.rows, input.liveInventory),
|
|
305
|
+
nav: buildNav(input.rows, input.liveInventory, input.nav),
|
|
273
306
|
tagsEmpty: input.rows
|
|
274
307
|
.filter((r) => isFrontPath(r.path) && (r.tags?.length ?? 0) === 0)
|
|
275
308
|
.map((r) => ({ path: r.path, locale: r.locale }))
|
|
@@ -291,8 +324,21 @@ export function renderSurfaceMarkdown(r) {
|
|
|
291
324
|
for (const m of cap(r.coverage.missingFromMap, 40))
|
|
292
325
|
L.push(` - \`${m.locale}/${m.path}\``);
|
|
293
326
|
}
|
|
294
|
-
L.push('', '## 导航 Nav
|
|
295
|
-
|
|
327
|
+
L.push('', '## 导航 Nav(真相 = 实时导航树,非页面树推断)', '');
|
|
328
|
+
if (!r.nav.available) {
|
|
329
|
+
L.push('- ⚠ 导航树不可读(nav.available=false)— 机器段暴露无法核验,请检查 token 的导航读取权限');
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
L.push(`- mode: \`${r.nav.mode}\` · 文件系统暴露 filesystemExposed: ${r.nav.filesystemExposed ? '⚠ 是 — DYNAMIC/MIXED 会把页面树镜像回侧栏(Issue #1 复发)' : '否'}`);
|
|
333
|
+
if (r.nav.machineLinks.length > 0) {
|
|
334
|
+
L.push(`- ⚠ 导航树内机器段链接 machineLinks (${r.nav.machineLinks.length}):`);
|
|
335
|
+
for (const m of cap(r.nav.machineLinks, 20))
|
|
336
|
+
L.push(` - [${m.locale}] ${m.label} → \`${m.target}\``);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
L.push(`- 导航树内机器段链接: none ✓(页面树存档段 ${r.nav.machinePaths.map((s) => `\`${s}/\``).join(' ') || '—'} 属设计内,仅备查)`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
296
342
|
if (r.nav.sectionLandingMissing.length > 0) {
|
|
297
343
|
L.push(`- 落地页缺失 sectionLandingMissing(面包屑 404 / 空目录页):`);
|
|
298
344
|
for (const s of r.nav.sectionLandingMissing)
|
|
@@ -599,7 +599,7 @@ export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与
|
|
|
599
599
|
|
|
600
600
|
| 元数据 | 值 |
|
|
601
601
|
| --- | --- |
|
|
602
|
-
| 状态 | <!--
|
|
602
|
+
| 状态 | draft <!-- 必须与顶部 状态/Status 头同 token;改状态时两处一起改 --> |
|
|
603
603
|
| 上次核实 | <!-- YYYY-MM-DD,在哪套环境按本页步骤重跑过 --> |
|
|
604
604
|
| 复核周期 | <!-- 如每 90 天,到期重跑本页步骤 --> |
|
|
605
605
|
| 被取代于 | <!-- 新手册路径,无则填 — --> |
|
|
@@ -648,7 +648,7 @@ export const G6_EN = `# How to Do X (placeholder: goal-titled heading, must matc
|
|
|
648
648
|
|
|
649
649
|
| Field | Value |
|
|
650
650
|
| --- | --- |
|
|
651
|
-
| Status | <!--
|
|
651
|
+
| Status | draft <!-- must carry the SAME token as the 状态/Status header above; change both together --> |
|
|
652
652
|
| Last verified | <!-- YYYY-MM-DD and the environment the steps were re-run in --> |
|
|
653
653
|
| Review by | <!-- e.g. every 90 days; re-run the steps when due --> |
|
|
654
654
|
| Superseded by | <!-- path of the newer manual, or — --> |
|
package/dist/tools/local.js
CHANGED
|
@@ -10,9 +10,11 @@ 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
12
|
import { buildSurfaceReport, renderSurfaceMarkdown } from '../surface.js';
|
|
13
|
+
import { lintBody, statusTokenFindings } from '../lint.js';
|
|
13
14
|
import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
|
|
14
15
|
import { listPages, readPage } from '../wiki/pages.read.js';
|
|
15
|
-
import {
|
|
16
|
+
import { readPrimaryNav } from '../wiki/nav.js';
|
|
17
|
+
import { errEnvelope, isInternalPath, okJson, reportUrls, URL_MANDATE } from './shared.js';
|
|
16
18
|
const s = tool.schema;
|
|
17
19
|
const TRANSLATE_ARGS = {
|
|
18
20
|
text: s.string(),
|
|
@@ -63,6 +65,24 @@ const MAP_ARGS = {
|
|
|
63
65
|
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'),
|
|
64
66
|
};
|
|
65
67
|
const MapArgsSchema = s.object(MAP_ARGS);
|
|
68
|
+
/** P1 surfacing: lint's status-token-conflict finding per scanned body,
|
|
69
|
+
* reusing runMaintain's per-page body cache (zero extra wiki reads). Machine
|
|
70
|
+
* rows carry the `status-token-conflict` key so harness graders can count it
|
|
71
|
+
* without re-deriving the rule. */
|
|
72
|
+
async function scanStatusTokenConflicts(rows, readBody, baseUrl) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
if (isInternalPath(r.path))
|
|
76
|
+
continue;
|
|
77
|
+
const body = await readBody(r.path, r.locale);
|
|
78
|
+
if (body === null || body === '')
|
|
79
|
+
continue;
|
|
80
|
+
const findings = statusTokenFindings(lintBody(body, { locale: r.locale, baseUrl, title: r.title }));
|
|
81
|
+
for (const f of findings)
|
|
82
|
+
out.push({ path: r.path, locale: r.locale, key: f.key, detail: f.detail });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
66
86
|
/** maintain: light tier is map rows + ONE read-only pages.list pass per locale
|
|
67
87
|
* (the mirror's MapRow carries no tags; the list join restores the vocab view);
|
|
68
88
|
* deep additionally reads each body via readPage. Reserved-path pages (e.g.
|
|
@@ -102,23 +122,33 @@ async function runMaintain(deps, mapDeps, snapshot, deep) {
|
|
|
102
122
|
}
|
|
103
123
|
: undefined;
|
|
104
124
|
const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
|
|
125
|
+
const nav = await readPrimaryNav(client);
|
|
105
126
|
const surface = await buildSurfaceReport({
|
|
106
127
|
rows,
|
|
107
128
|
generatedAt: report.generatedAt,
|
|
108
129
|
baseUrl: deps.options.baseUrl,
|
|
109
130
|
liveInventory,
|
|
131
|
+
nav,
|
|
110
132
|
deep,
|
|
111
133
|
readBody,
|
|
112
134
|
});
|
|
135
|
+
const statusTokenConflicts = deep && readBody !== undefined
|
|
136
|
+
? await scanStatusTokenConflicts(rows, readBody, deps.options.baseUrl)
|
|
137
|
+
: [];
|
|
138
|
+
const conflictNote = statusTokenConflicts.length === 0
|
|
139
|
+
? ''
|
|
140
|
+
: `\n\n## 状态令牌冲突 Status-token conflicts (${statusTokenConflicts.length})\n` +
|
|
141
|
+
statusTokenConflicts.map((c) => `- \`${c.locale}/${c.path}\` status-token-conflict: ${c.detail}`).join('\n') +
|
|
142
|
+
'\n';
|
|
113
143
|
return {
|
|
114
144
|
action: 'maintain',
|
|
115
|
-
schema: 'historian.maintain.
|
|
145
|
+
schema: 'historian.maintain.v3',
|
|
116
146
|
deep: report.deep,
|
|
117
147
|
generatedAt: report.generatedAt,
|
|
118
148
|
rowCount: report.rowCount,
|
|
119
|
-
report,
|
|
149
|
+
report: { ...report, statusTokenConflicts },
|
|
120
150
|
surface,
|
|
121
|
-
markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
|
|
151
|
+
markdown: `${renderMaintainMarkdown(report)}${conflictNote}\n\n${renderSurfaceMarkdown(surface)}`,
|
|
122
152
|
urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
|
|
123
153
|
};
|
|
124
154
|
}
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -129,9 +129,10 @@ export declare function sectionRefusalJson(path: string, allowedSections: readon
|
|
|
129
129
|
export declare class PublishGateError extends Error {
|
|
130
130
|
constructor(message: string);
|
|
131
131
|
}
|
|
132
|
-
/** Hard gate on front-tier writes: refuses the
|
|
132
|
+
/** Hard gate on front-tier writes: refuses the three shapes that shipped real
|
|
133
133
|
* incidents — a page claiming Active with TODO markers or empty skeleton
|
|
134
|
-
* sections,
|
|
134
|
+
* sections, a redirect stub whose body carries no clickable exit, and (R4/P1)
|
|
135
|
+
* an Active write whose table-row 状态/Status token contradicts the header.
|
|
135
136
|
* `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
|
|
136
137
|
* sanctioned work-in-progress escape hatch. Null = proceed. */
|
|
137
138
|
export declare function publishGateRefusalJson(content: string, locale: Locale, path: string, baseUrl: string): ToolResult | null;
|
package/dist/tools/shared.js
CHANGED
|
@@ -85,7 +85,7 @@ function hintFor(errorKind) {
|
|
|
85
85
|
case 'GraphQLError':
|
|
86
86
|
return 'The wiki answered a GraphQL error — check the path/locale arguments.';
|
|
87
87
|
case 'PublishGateError':
|
|
88
|
-
return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL)
|
|
88
|
+
return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL);Active 页的表格行 状态/Status 必须与顶部冒号状态头同 token(改状态两处一起改)。';
|
|
89
89
|
default:
|
|
90
90
|
return 'Inspect the message and retry.';
|
|
91
91
|
}
|
|
@@ -314,9 +314,10 @@ export class PublishGateError extends Error {
|
|
|
314
314
|
this.name = 'PublishGateError';
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
-
/** Hard gate on front-tier writes: refuses the
|
|
317
|
+
/** Hard gate on front-tier writes: refuses the three shapes that shipped real
|
|
318
318
|
* incidents — a page claiming Active with TODO markers or empty skeleton
|
|
319
|
-
* sections,
|
|
319
|
+
* sections, a redirect stub whose body carries no clickable exit, and (R4/P1)
|
|
320
|
+
* an Active write whose table-row 状态/Status token contradicts the header.
|
|
320
321
|
* `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
|
|
321
322
|
* sanctioned work-in-progress escape hatch. Null = proceed. */
|
|
322
323
|
export function publishGateRefusalJson(content, locale, path, baseUrl) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type GqlClient } from './client.js';
|
|
2
|
+
export interface NavItem {
|
|
3
|
+
readonly label: string;
|
|
4
|
+
readonly targetType: string;
|
|
5
|
+
readonly target: string;
|
|
6
|
+
}
|
|
7
|
+
export interface NavTree {
|
|
8
|
+
readonly locale: string;
|
|
9
|
+
readonly items: readonly NavItem[];
|
|
10
|
+
}
|
|
11
|
+
export interface NavSnapshot {
|
|
12
|
+
readonly mode: string;
|
|
13
|
+
readonly trees: readonly NavTree[];
|
|
14
|
+
}
|
|
15
|
+
/** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
|
|
16
|
+
* payload without `navigation` is not-a-nav (null), letting the caller mark
|
|
17
|
+
* the check unavailable instead of claiming "clean". */
|
|
18
|
+
export declare function parseNav(raw: unknown): NavSnapshot | null;
|
|
19
|
+
/** Never throws: an unreadable nav (older server, token without navigation
|
|
20
|
+
* read) returns null so the scan degrades to "unavailable", not failure. */
|
|
21
|
+
export declare function readPrimaryNav(client: GqlClient): Promise<NavSnapshot | null>;
|
package/dist/wiki/nav.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Live navigation reader (v0.5.1). Issue #1's actual disease was the SIDEBAR
|
|
2
|
+
// mirroring the filesystem (DYNAMIC/MIXED exposing _meta/_evidence/_sandbox),
|
|
3
|
+
// not the existence of machine-namespace pages — those are by design. So the
|
|
4
|
+
// detector reads the real primary nav (mode + curated flat trees) instead of
|
|
5
|
+
// inferring exposure from the page tree.
|
|
6
|
+
//
|
|
7
|
+
// Shape note: this wiki.js generation REJECTS `children` on NavigationItem
|
|
8
|
+
// (HTTP 400, re-probed 2026-09-08) — flat `items` are the whole truth.
|
|
9
|
+
import { gql } from './client.js';
|
|
10
|
+
const NAV_QUERY = '{ navigation { config { mode } tree { locale items { label targetType target } } } }';
|
|
11
|
+
/** Shape-tolerant parse: unknown/nullish shapes degrade to '' entries; a
|
|
12
|
+
* payload without `navigation` is not-a-nav (null), letting the caller mark
|
|
13
|
+
* the check unavailable instead of claiming "clean". */
|
|
14
|
+
export function parseNav(raw) {
|
|
15
|
+
const nav = raw?.navigation;
|
|
16
|
+
if (nav === undefined || nav === null)
|
|
17
|
+
return null;
|
|
18
|
+
const trees = [];
|
|
19
|
+
for (const t of Array.isArray(nav.tree) ? nav.tree : []) {
|
|
20
|
+
const row = t;
|
|
21
|
+
const items = [];
|
|
22
|
+
for (const i of Array.isArray(row?.items) ? row.items : []) {
|
|
23
|
+
const it = i;
|
|
24
|
+
items.push({
|
|
25
|
+
label: typeof it?.label === 'string' ? it.label : '',
|
|
26
|
+
targetType: typeof it?.targetType === 'string' ? it.targetType : '',
|
|
27
|
+
target: typeof it?.target === 'string' ? it.target : '',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
trees.push({ locale: typeof row?.locale === 'string' ? row.locale : '', items });
|
|
31
|
+
}
|
|
32
|
+
return { mode: typeof nav.config?.mode === 'string' ? nav.config.mode : '', trees };
|
|
33
|
+
}
|
|
34
|
+
/** Never throws: an unreadable nav (older server, token without navigation
|
|
35
|
+
* read) returns null so the scan degrades to "unavailable", not failure. */
|
|
36
|
+
export async function readPrimaryNav(client) {
|
|
37
|
+
try {
|
|
38
|
+
return parseNav(await gql(client, NAV_QUERY, {}));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
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 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.
|
|
203
|
+
| `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v3`) |
|
|
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,21 +327,21 @@ historian_map action:'refresh'
|
|
|
327
327
|
- **light 扫:每次批量写后必跑**——只基于地图行 + 每 locale 一次只读 `pages.list`(便宜,随批走)
|
|
328
328
|
- **deep 扫:每周至多一次**——逐页读正文,跑新鲜度(缺「上次核实于」/ 复核过期)与 `> Redirect:` 存根计数(贵,克制用)
|
|
329
329
|
|
|
330
|
-
light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav
|
|
330
|
+
light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(侧栏真相=实时导航树:mode 非 STATIC 即把页面树镜像回侧栏 / 树内挂 `_` 段链接 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
|
|
331
331
|
|
|
332
332
|
报告行 → 处置映射表:
|
|
333
333
|
|
|
334
334
|
| 报告行 | 含义 | 处置 |
|
|
335
335
|
|--------|------|------|
|
|
336
336
|
| `missingTwinPaths` | 双语孪生缺口 | 补孪生:翻译腿建 zh(或 en)页,走翻译失败处理 |
|
|
337
|
-
| `duplicates.clusters` | 近重复标题簇(trigram-Jaccard
|
|
337
|
+
| `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值;「(重定向)」存根不参与聚类) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
|
|
338
338
|
| `staleness.oldest` | 最陈旧页 | mark/refresh:G5 卡重新核实或标记 stale,不静默覆盖 |
|
|
339
|
-
| `
|
|
339
|
+
| `flatRootPages` / `diffusion.singleChildDirs` | 章节根平铺页清单(归架提示,非入链判定)/ 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根;真孤儿看 surface deep 的 `orphanPages` |
|
|
340
340
|
| `tags.vocabulary` | 标签漂移 | 词表映射:近义标签收敛到主词,`historian_page_update` 批量改 |
|
|
341
341
|
| `redirects.stubs`(deep) | 重定向存根清单 | 核对目标存在、入链已改写;死链存根即修 |
|
|
342
342
|
| `freshness`(deep) | 缺核实戳 / reviewBy 过期 | 回 G5 卡补核;到期页列入下周复核 |
|
|
343
343
|
| `coverage.missingFromMap`(surface) | 新页/迁移未进地图 | `action:'refresh'` 后重扫 |
|
|
344
|
-
| `nav.
|
|
344
|
+
| `nav.filesystemExposed` / `nav.machineLinks`(surface) | 侧栏被 DYNAMIC/MIXED 镜像出页面树 / 导航树里挂了 `_` 段链接 | 导航树手工策划只挂主题章节,mode 固定 STATIC;`nav.available=false` 时先修 token 导航读权限再下结论 |
|
|
345
345
|
| `nav.sectionLandingMissing`(surface) | 章节缺落地页(面包屑 404) | 建章节总览页并链入 wiki-index |
|
|
346
346
|
| `unfinished`(surface deep) | Active 页含 TODO/空节/导言空 | 补全或降回 draft |
|
|
347
347
|
| `stubs` / `links.broken` / `toStubs` / `sameTargetStacks`(deep) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |
|