opencode-wiki-historian 0.5.1 → 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 +22 -0
- package/dist/maintain.js +154 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +15 -1
- package/dist/templates/skeletons.js +2 -2
- package/dist/tools/local.js +30 -3
- package/dist/tools/shared.d.ts +3 -2
- package/dist/tools/shared.js +4 -3
- package/package.json +1 -1
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;
|
|
@@ -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
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
|
@@ -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) {
|
|
@@ -204,6 +229,117 @@ function reviewByOf(body) {
|
|
|
204
229
|
}
|
|
205
230
|
return null;
|
|
206
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
|
+
}
|
|
207
343
|
// --- buildMaintainReport --------------------------------------------------------
|
|
208
344
|
export async function buildMaintainReport(input, opts = {}) {
|
|
209
345
|
const now = opts.now ?? new Date();
|
|
@@ -222,11 +358,13 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
222
358
|
localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
|
|
223
359
|
let redirects = { available: false, count: 0, stubs: [] };
|
|
224
360
|
let freshness = null;
|
|
361
|
+
let dueForReview = null;
|
|
225
362
|
if (deep && opts.readBody !== undefined) {
|
|
226
363
|
const readBody = opts.readBody;
|
|
227
364
|
const stubs = [];
|
|
228
365
|
const missing = [];
|
|
229
366
|
const expired = [];
|
|
367
|
+
const due = [];
|
|
230
368
|
let scanned = 0;
|
|
231
369
|
let unreadable = 0;
|
|
232
370
|
for (const r of kept) {
|
|
@@ -242,6 +380,9 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
242
380
|
continue; // stubs are pointers — exempt from the freshness-stamp rule
|
|
243
381
|
}
|
|
244
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 });
|
|
245
386
|
if (!FRESHNESS_GENRES.includes(genre))
|
|
246
387
|
continue;
|
|
247
388
|
if (!STAMP_RE.test(body))
|
|
@@ -256,6 +397,7 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
256
397
|
}
|
|
257
398
|
redirects = { available: true, count: stubs.length, stubs };
|
|
258
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));
|
|
259
401
|
}
|
|
260
402
|
return {
|
|
261
403
|
schema: MAINTAIN_SCHEMA,
|
|
@@ -278,6 +420,7 @@ export async function buildMaintainReport(input, opts = {}) {
|
|
|
278
420
|
redirects,
|
|
279
421
|
sections: sectionDist(kept),
|
|
280
422
|
freshness,
|
|
423
|
+
dueForReview,
|
|
281
424
|
};
|
|
282
425
|
}
|
|
283
426
|
// --- renderMaintainMarkdown -----------------------------------------------------
|
|
@@ -350,6 +493,17 @@ export function renderMaintainMarkdown(r) {
|
|
|
350
493
|
for (const e of r.freshness.expiredReviewBy)
|
|
351
494
|
L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
|
|
352
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
|
+
}
|
|
353
507
|
L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
|
|
354
508
|
return L.join('\n');
|
|
355
509
|
}
|
package/dist/surface.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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
16
|
import type { NavSnapshot } from './wiki/nav.js';
|
|
17
17
|
export declare const SURFACE_SCHEMA: "historian.surface.v1";
|
package/dist/surface.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
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 = /^_/;
|
|
19
20
|
// nav targets carry a leading slash and may pre-pend the locale segment
|
|
@@ -111,6 +112,13 @@ function resolveTargetPath(baseUrl, rawHref) {
|
|
|
111
112
|
return { path: t.slice(3), locale: 'en' };
|
|
112
113
|
return { path: t, locale: 'en' };
|
|
113
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
|
+
}
|
|
114
122
|
async function scanBodies(input) {
|
|
115
123
|
if (input.readBody === undefined)
|
|
116
124
|
return [];
|
|
@@ -252,7 +260,13 @@ function buildDeep(input, scans) {
|
|
|
252
260
|
.map((s) => ({ path: s.row.path, cjkRatio: Number(s.lint.cjkRatio.toFixed(3)) }));
|
|
253
261
|
const ledgerClaims = [];
|
|
254
262
|
for (const s of ok) {
|
|
255
|
-
|
|
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)
|
|
256
270
|
continue;
|
|
257
271
|
const genre = classifyGenre({ title: s.row.title, body: s.body }).genre;
|
|
258
272
|
if (!(genre === 'G4' || genre === 'G5' || genre === 'G6'))
|
|
@@ -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,10 +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
16
|
import { readPrimaryNav } from '../wiki/nav.js';
|
|
16
|
-
import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
|
|
17
|
+
import { errEnvelope, isInternalPath, okJson, reportUrls, URL_MANDATE } from './shared.js';
|
|
17
18
|
const s = tool.schema;
|
|
18
19
|
const TRANSLATE_ARGS = {
|
|
19
20
|
text: s.string(),
|
|
@@ -64,6 +65,24 @@ const MAP_ARGS = {
|
|
|
64
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'),
|
|
65
66
|
};
|
|
66
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
|
+
}
|
|
67
86
|
/** maintain: light tier is map rows + ONE read-only pages.list pass per locale
|
|
68
87
|
* (the mirror's MapRow carries no tags; the list join restores the vocab view);
|
|
69
88
|
* deep additionally reads each body via readPage. Reserved-path pages (e.g.
|
|
@@ -113,15 +132,23 @@ async function runMaintain(deps, mapDeps, snapshot, deep) {
|
|
|
113
132
|
deep,
|
|
114
133
|
readBody,
|
|
115
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';
|
|
116
143
|
return {
|
|
117
144
|
action: 'maintain',
|
|
118
145
|
schema: 'historian.maintain.v3',
|
|
119
146
|
deep: report.deep,
|
|
120
147
|
generatedAt: report.generatedAt,
|
|
121
148
|
rowCount: report.rowCount,
|
|
122
|
-
report,
|
|
149
|
+
report: { ...report, statusTokenConflicts },
|
|
123
150
|
surface,
|
|
124
|
-
markdown: `${renderMaintainMarkdown(report)}\n\n${renderSurfaceMarkdown(surface)}`,
|
|
151
|
+
markdown: `${renderMaintainMarkdown(report)}${conflictNote}\n\n${renderSurfaceMarkdown(surface)}`,
|
|
125
152
|
urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
|
|
126
153
|
};
|
|
127
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) {
|
package/package.json
CHANGED