opencode-wiki-historian 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Maintain curation report (plan todo-7, D3 CURATE / D12): deterministic
3
+ * metric sweeps over already-fetched map rows (tier-1 light) plus optional
4
+ * injected page-body reads (deep). The chronology.ts precedent holds here —
5
+ * no client, no fs, no wiki logic: pure functions over MapRow-shaped input and
6
+ * a readBody dependency; the tool layer (tools/local.ts) supplies both.
7
+ *
8
+ * Output is dual-form: a human markdown report (renderMaintainMarkdown) and a
9
+ * machine-readable JSON tail with stable top-level keys (schema
10
+ * 'historian.maintain.v1') so later todos can parse it without re-deriving.
11
+ *
12
+ * allow: SIZE_OK — the plan pins this commit to src/maintain.ts +
13
+ * tools/local.ts + test/maintain.test.ts only, so the metric builder and its
14
+ * markdown renderer ship as one module instead of a third file.
15
+ */
16
+ import type { MapRow } from './map.js';
17
+ import type { Locale } from './wiki/pages.read.js';
18
+ /** A map row optionally enriched with tags (the tool layer joins these from a
19
+ * read-only pages.list pass; the mirror's MapRow carries none). */
20
+ export interface MaintainRow extends MapRow {
21
+ readonly tags?: readonly string[];
22
+ }
23
+ export interface MaintainInput {
24
+ readonly rows: readonly MaintainRow[];
25
+ readonly mapGeneratedAt?: string | null;
26
+ readonly mapStaleSeconds?: number | null;
27
+ }
28
+ export type ReadBodyFn = (path: string, locale: Locale) => Promise<string | null>;
29
+ export interface MaintainOptions {
30
+ readonly now?: Date;
31
+ readonly topN?: number;
32
+ readonly deep?: boolean;
33
+ readonly readBody?: ReadBodyFn;
34
+ }
35
+ export interface DupCluster {
36
+ readonly paths: readonly string[];
37
+ readonly titles: readonly string[];
38
+ }
39
+ export interface StaleEntry {
40
+ readonly path: string;
41
+ readonly updatedAt: string;
42
+ readonly daysOld: number;
43
+ readonly locales: readonly string[];
44
+ }
45
+ export interface RedirectStub {
46
+ readonly path: string;
47
+ readonly locale: Locale;
48
+ readonly target: string;
49
+ }
50
+ export interface MissingStamp {
51
+ readonly path: string;
52
+ readonly locale: Locale;
53
+ readonly genre: string;
54
+ }
55
+ export interface ExpiredReview {
56
+ readonly path: string;
57
+ readonly locale: Locale;
58
+ readonly reviewBy: string;
59
+ readonly daysExpired: number;
60
+ }
61
+ export interface FreshnessScan {
62
+ readonly scanned: number;
63
+ readonly unreadable: number;
64
+ readonly missingLastVerified: readonly MissingStamp[];
65
+ readonly expiredReviewBy: readonly ExpiredReview[];
66
+ }
67
+ export interface MaintainReport {
68
+ readonly schema: typeof MAINTAIN_SCHEMA;
69
+ readonly generatedAt: string;
70
+ readonly rowCount: number;
71
+ readonly mapGeneratedAt: string | null;
72
+ readonly mapStaleSeconds: number | null;
73
+ readonly deep: boolean;
74
+ readonly pages: {
75
+ readonly rows: number;
76
+ readonly paths: number;
77
+ readonly perLocale: Readonly<Record<string, number>>;
78
+ readonly missingTwinPaths: readonly string[];
79
+ };
80
+ readonly duplicates: {
81
+ readonly threshold: number;
82
+ readonly clusters: readonly DupCluster[];
83
+ };
84
+ readonly staleness: {
85
+ readonly topN: number;
86
+ readonly oldest: readonly StaleEntry[];
87
+ };
88
+ readonly diffusion: {
89
+ readonly singleChildDirs: readonly {
90
+ dir: string;
91
+ childPath: string;
92
+ }[];
93
+ };
94
+ readonly rootOrphans: readonly {
95
+ section: string;
96
+ paths: readonly string[];
97
+ }[];
98
+ readonly tags: {
99
+ available: boolean;
100
+ vocabulary: readonly {
101
+ tag: string;
102
+ count: number;
103
+ }[];
104
+ };
105
+ readonly redirects: {
106
+ available: boolean;
107
+ count: number;
108
+ stubs: readonly RedirectStub[];
109
+ };
110
+ readonly sections: readonly {
111
+ section: string;
112
+ paths: number;
113
+ rows: number;
114
+ }[];
115
+ readonly freshness: FreshnessScan | null;
116
+ }
117
+ export declare const MAINTAIN_SCHEMA = "historian.maintain.v1";
118
+ /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
119
+ export declare const DUP_TITLE_THRESHOLD = 0.75;
120
+ export declare function buildMaintainReport(input: MaintainInput, opts?: MaintainOptions): Promise<MaintainReport>;
121
+ /** Human report + (always) a fenced machine-readable JSON block at the END —
122
+ * the same MaintainReport object the tool envelope carries. */
123
+ export declare function renderMaintainMarkdown(r: MaintainReport): string;
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Maintain curation report (plan todo-7, D3 CURATE / D12): deterministic
3
+ * metric sweeps over already-fetched map rows (tier-1 light) plus optional
4
+ * injected page-body reads (deep). The chronology.ts precedent holds here —
5
+ * no client, no fs, no wiki logic: pure functions over MapRow-shaped input and
6
+ * a readBody dependency; the tool layer (tools/local.ts) supplies both.
7
+ *
8
+ * Output is dual-form: a human markdown report (renderMaintainMarkdown) and a
9
+ * machine-readable JSON tail with stable top-level keys (schema
10
+ * 'historian.maintain.v1') so later todos can parse it without re-deriving.
11
+ *
12
+ * allow: SIZE_OK — the plan pins this commit to src/maintain.ts +
13
+ * tools/local.ts + test/maintain.test.ts only, so the metric builder and its
14
+ * markdown renderer ship as one module instead of a third file.
15
+ */
16
+ import { classifyGenre } from './templates/genres.js';
17
+ import { isInternalPath } from './tools/shared.js';
18
+ import { normalize } from './migrate-score.js';
19
+ // --- Constants --------------------------------------------------------------
20
+ export const MAINTAIN_SCHEMA = 'historian.maintain.v1';
21
+ /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
22
+ export const DUP_TITLE_THRESHOLD = 0.75;
23
+ const DAY_MS = 86_400_000;
24
+ const DEFAULT_TOP_N = 10;
25
+ /** Genres whose pages must carry the D4 last-verified stamp (G6 how-to joins
26
+ * this set when todo-2 lands it — string membership, no type coupling). */
27
+ // D4 freshness applies to knowledge-fact pages: G5 today; 'G6' is listed
28
+ // pre-emptively so the deep sweep lights up when the genre lane lands.
29
+ const FRESHNESS_GENRES = ['G5', 'G6'];
30
+ const STAMP_RE = /上次核实|last verified/i;
31
+ const REVIEW_LABEL_RE = /^(复核周期|复核期限|复核日期|review[-_ ]?by|review[-_ ]?due)$/i;
32
+ const ISO_DATE_RE = /\d{4}-\d{2}-\d{2}/;
33
+ const REDIRECT_RE = /^>\s*Redirect:/i;
34
+ // --- Title similarity (trigram core copied from migrate-score.ts:289, where it
35
+ // --- is private with a 0.95 roundtrip bar; maintain needs its own threshold) ---
36
+ function titleGrams(s) {
37
+ const out = new Set();
38
+ if (s.length < 3) {
39
+ if (s.length > 0)
40
+ out.add(s);
41
+ return out;
42
+ }
43
+ for (let i = 0; i <= s.length - 3; i++)
44
+ out.add(s.slice(i, i + 3));
45
+ return out;
46
+ }
47
+ function jaccard(a, b) {
48
+ if (a.size === 0 && b.size === 0)
49
+ return 1;
50
+ let inter = 0;
51
+ for (const g of a)
52
+ if (b.has(g))
53
+ inter++;
54
+ const union = a.size + b.size - inter;
55
+ return union === 0 ? 1 : inter / union;
56
+ }
57
+ // --- Metric builders ----------------------------------------------------------
58
+ function cmpStr(a, b) {
59
+ return a < b ? -1 : a > b ? 1 : 0;
60
+ }
61
+ function findDuplicates(rows) {
62
+ const units = rows.map((r) => ({
63
+ path: r.path,
64
+ title: r.title,
65
+ grams: titleGrams(normalize(r.title).toLowerCase()),
66
+ }));
67
+ const parent = units.map((_, i) => i);
68
+ const find = (i) => {
69
+ while (parent[i] !== i) {
70
+ parent[i] = parent[parent[i]];
71
+ i = parent[i];
72
+ }
73
+ return i;
74
+ };
75
+ for (let i = 0; i < units.length; i++) {
76
+ for (let j = i + 1; j < units.length; j++) {
77
+ // twins share a path by design — never a duplicate; cross-locale title
78
+ // similarity is low enough that same-path exclusion is the only guard needed
79
+ if (units[i].path === units[j].path)
80
+ continue;
81
+ if (jaccard(units[i].grams, units[j].grams) >= DUP_TITLE_THRESHOLD)
82
+ parent[find(i)] = find(j);
83
+ }
84
+ }
85
+ const clusters = new Map();
86
+ units.forEach((u, i) => {
87
+ const root = find(i);
88
+ const bucket = clusters.get(root) ?? { paths: new Set(), titles: new Set() };
89
+ bucket.paths.add(u.path);
90
+ bucket.titles.add(u.title);
91
+ clusters.set(root, bucket);
92
+ });
93
+ const out = [...clusters.values()]
94
+ .filter((c) => c.paths.size >= 2)
95
+ .map((c) => ({ paths: [...c.paths].sort(cmpStr), titles: [...c.titles].sort(cmpStr) }));
96
+ out.sort((a, b) => b.paths.length - a.paths.length || cmpStr(a.paths[0], b.paths[0]));
97
+ return out;
98
+ }
99
+ function staleness(rows, now, topN) {
100
+ const byPath = new Map();
101
+ for (const r of rows) {
102
+ const t = Date.parse(r.updatedAt);
103
+ if (Number.isNaN(t))
104
+ continue;
105
+ const cur = byPath.get(r.path);
106
+ if (cur === undefined)
107
+ byPath.set(r.path, { t, updatedAt: r.updatedAt, locales: new Set([r.locale]) });
108
+ else {
109
+ cur.locales.add(r.locale);
110
+ if (t > cur.t) {
111
+ cur.t = t;
112
+ cur.updatedAt = r.updatedAt;
113
+ }
114
+ }
115
+ }
116
+ const round1 = (x) => Math.round(x * 10) / 10;
117
+ return [...byPath.entries()]
118
+ .map(([path, v]) => ({
119
+ path,
120
+ updatedAt: v.updatedAt,
121
+ daysOld: round1((now.getTime() - v.t) / DAY_MS),
122
+ locales: [...v.locales].sort(cmpStr),
123
+ }))
124
+ .sort((a, b) => a.updatedAt.localeCompare(b.updatedAt) || cmpStr(a.path, b.path))
125
+ .slice(0, topN);
126
+ }
127
+ function singleChildDirs(paths) {
128
+ const subtree = new Map();
129
+ for (const path of paths) {
130
+ const segs = path.split('/');
131
+ for (let i = 1; i < segs.length; i++) {
132
+ const dir = segs.slice(0, i).join('/');
133
+ const bucket = subtree.get(dir) ?? new Set();
134
+ bucket.add(path);
135
+ subtree.set(dir, bucket);
136
+ }
137
+ }
138
+ const single = new Set([...subtree.entries()].filter(([, v]) => v.size === 1).map(([d]) => d));
139
+ const out = [];
140
+ for (const dir of [...single].sort(cmpStr)) {
141
+ const cut = dir.lastIndexOf('/');
142
+ if (cut > 0 && single.has(dir.slice(0, cut)))
143
+ continue; // report the shallowest of a chain
144
+ const children = subtree.get(dir);
145
+ if (children !== undefined)
146
+ out.push({ dir, childPath: [...children][0] });
147
+ }
148
+ return out;
149
+ }
150
+ function rootOrphans(paths) {
151
+ const bySection = new Map();
152
+ for (const path of paths) {
153
+ if (path.split('/').length !== 2)
154
+ continue;
155
+ const section = path.split('/')[0];
156
+ const bucket = bySection.get(section) ?? [];
157
+ bucket.push(path);
158
+ bySection.set(section, bucket);
159
+ }
160
+ return [...bySection.entries()]
161
+ .map(([section, ps]) => ({ section, paths: ps.sort(cmpStr) }))
162
+ .sort((a, b) => cmpStr(a.section, b.section));
163
+ }
164
+ function tagVocab(rows) {
165
+ const available = rows.some((r) => r.tags !== undefined);
166
+ if (!available)
167
+ return { available: false, vocabulary: [] };
168
+ const counts = new Map();
169
+ for (const r of rows)
170
+ for (const tag of r.tags ?? [])
171
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
172
+ const vocabulary = [...counts.entries()]
173
+ .map(([tag, count]) => ({ tag, count }))
174
+ .sort((a, b) => b.count - a.count || cmpStr(a.tag, b.tag));
175
+ return { available: true, vocabulary };
176
+ }
177
+ function sectionDist(rows) {
178
+ const bySection = new Map();
179
+ for (const r of rows) {
180
+ const section = r.path.split('/')[0];
181
+ const bucket = bySection.get(section) ?? { paths: new Set(), rows: 0 };
182
+ bucket.paths.add(r.path);
183
+ bucket.rows += 1;
184
+ bySection.set(section, bucket);
185
+ }
186
+ return [...bySection.entries()]
187
+ .map(([section, v]) => ({ section, paths: v.paths.size, rows: v.rows }))
188
+ .sort((a, b) => b.paths - a.paths || cmpStr(a.section, b.section));
189
+ }
190
+ /** D4 review-by row inside a 元数据 markdown table (wiki.js has no front matter). */
191
+ function reviewByOf(body) {
192
+ for (const line of body.split('\n')) {
193
+ if (!line.trimStart().startsWith('|'))
194
+ continue;
195
+ const cells = line.split('|').map((c) => c.trim());
196
+ if (cells.length < 3 || !REVIEW_LABEL_RE.test(cells[1]))
197
+ continue;
198
+ const m = cells.slice(2).join(' ').match(ISO_DATE_RE);
199
+ if (m !== null)
200
+ return m[0];
201
+ }
202
+ return null;
203
+ }
204
+ // --- buildMaintainReport --------------------------------------------------------
205
+ export async function buildMaintainReport(input, opts = {}) {
206
+ const now = opts.now ?? new Date();
207
+ const topN = opts.topN ?? DEFAULT_TOP_N;
208
+ const deep = opts.deep ?? false;
209
+ if (deep && opts.readBody === undefined) {
210
+ throw new TypeError('buildMaintainReport: deep:true requires a readBody dependency');
211
+ }
212
+ const kept = input.rows.filter((r) => !isInternalPath(r.path));
213
+ const paths = [...new Set(kept.map((r) => r.path))].sort(cmpStr);
214
+ const perLocale = {};
215
+ for (const r of kept)
216
+ perLocale[r.locale] = (perLocale[r.locale] ?? 0) + 1;
217
+ const localeCount = new Map();
218
+ for (const r of kept)
219
+ localeCount.set(r.path, (localeCount.get(r.path) ?? 0) + 1);
220
+ let redirects = { available: false, count: 0, stubs: [] };
221
+ let freshness = null;
222
+ if (deep && opts.readBody !== undefined) {
223
+ const readBody = opts.readBody;
224
+ const stubs = [];
225
+ const missing = [];
226
+ const expired = [];
227
+ let scanned = 0;
228
+ let unreadable = 0;
229
+ for (const r of kept) {
230
+ const body = await readBody(r.path, r.locale);
231
+ if (body === null) {
232
+ unreadable += 1;
233
+ continue;
234
+ }
235
+ scanned += 1;
236
+ const firstLine = body.split('\n').find((l) => l.trim() !== '');
237
+ if (firstLine !== undefined && REDIRECT_RE.test(firstLine.trim())) {
238
+ stubs.push({ path: r.path, locale: r.locale, target: firstLine.trim().replace(REDIRECT_RE, '').trim() });
239
+ continue; // stubs are pointers — exempt from the freshness-stamp rule
240
+ }
241
+ const genre = classifyGenre({ title: r.title, body }).genre;
242
+ if (!FRESHNESS_GENRES.includes(genre))
243
+ continue;
244
+ if (!STAMP_RE.test(body))
245
+ missing.push({ path: r.path, locale: r.locale, genre });
246
+ const reviewBy = reviewByOf(body);
247
+ if (reviewBy !== null) {
248
+ const t = Date.parse(reviewBy);
249
+ if (!Number.isNaN(t) && t < now.getTime()) {
250
+ expired.push({ path: r.path, locale: r.locale, reviewBy, daysExpired: Math.floor((now.getTime() - t) / DAY_MS) });
251
+ }
252
+ }
253
+ }
254
+ redirects = { available: true, count: stubs.length, stubs };
255
+ freshness = { scanned, unreadable, missingLastVerified: missing, expiredReviewBy: expired };
256
+ }
257
+ return {
258
+ schema: MAINTAIN_SCHEMA,
259
+ generatedAt: now.toISOString(),
260
+ rowCount: input.rows.length,
261
+ mapGeneratedAt: input.mapGeneratedAt ?? null,
262
+ mapStaleSeconds: input.mapStaleSeconds ?? null,
263
+ deep,
264
+ pages: {
265
+ rows: kept.length,
266
+ paths: paths.length,
267
+ perLocale,
268
+ missingTwinPaths: paths.filter((p) => localeCount.get(p) === 1),
269
+ },
270
+ duplicates: { threshold: DUP_TITLE_THRESHOLD, clusters: findDuplicates(kept) },
271
+ staleness: { topN, oldest: staleness(kept, now, topN) },
272
+ diffusion: { singleChildDirs: singleChildDirs(paths) },
273
+ rootOrphans: rootOrphans(paths),
274
+ tags: tagVocab(kept),
275
+ redirects,
276
+ sections: sectionDist(kept),
277
+ freshness,
278
+ };
279
+ }
280
+ // --- renderMaintainMarkdown -----------------------------------------------------
281
+ const fmt = (n) => String(n);
282
+ /** Human report + (always) a fenced machine-readable JSON block at the END —
283
+ * the same MaintainReport object the tool envelope carries. */
284
+ export function renderMaintainMarkdown(r) {
285
+ const L = [];
286
+ L.push(`# Maintain Report (${r.deep ? 'deep' : 'light'})`, '');
287
+ L.push(`> Generated ${r.generatedAt} · ${fmt(r.rowCount)} map rows · ` +
288
+ `map snapshot ${r.mapGeneratedAt ?? 'live build'}` +
289
+ `${r.mapStaleSeconds === null ? '' : ` · stale ${fmt(r.mapStaleSeconds)}s`}`, '');
290
+ L.push('## Pages & twins', '');
291
+ L.push(`- ${fmt(r.pages.rows)} kept rows / ${fmt(r.pages.paths)} paths (${Object.entries(r.pages.perLocale)
292
+ .map(([l, n]) => `${l}:${fmt(n)}`)
293
+ .join(' ')})`);
294
+ L.push(`- twin gap — paths missing one locale (${fmt(r.pages.missingTwinPaths.length)}):`);
295
+ for (const p of r.pages.missingTwinPaths)
296
+ L.push(` - \`${p}\``);
297
+ L.push('', `## Near-duplicate title clusters (threshold ${r.duplicates.threshold})`, '');
298
+ if (r.duplicates.clusters.length === 0)
299
+ L.push('- none');
300
+ r.duplicates.clusters.forEach((c, i) => {
301
+ L.push(`${i + 1}. ${c.titles.map((t) => `"${t}"`).join(' ≡ ')}`);
302
+ for (const p of c.paths)
303
+ L.push(` - \`${p}\``);
304
+ });
305
+ L.push('', `## Staleness — oldest ${fmt(r.staleness.topN)} paths`, '');
306
+ L.push('| Path | Updated | Days old | Locales |', '| --- | --- | --- | --- |');
307
+ for (const s of r.staleness.oldest) {
308
+ L.push(`| \`${s.path}\` | ${s.updatedAt.slice(0, 10)} | ${fmt(s.daysOld)} | ${s.locales.join(',')} |`);
309
+ }
310
+ L.push('', '## Diffusion candidates', '');
311
+ L.push('- single-child dirs (upmerge candidates):');
312
+ if (r.diffusion.singleChildDirs.length === 0)
313
+ L.push(' - none');
314
+ for (const d of r.diffusion.singleChildDirs)
315
+ L.push(` - \`${d.dir}/\` holds only \`${d.childPath}\``);
316
+ L.push('- root-level orphans (depth-2 pages, no sub-shelf):');
317
+ if (r.rootOrphans.length === 0)
318
+ L.push(' - none');
319
+ for (const o of r.rootOrphans)
320
+ L.push(` - \`${o.section}/\` (${fmt(o.paths.length)}): ${o.paths.map((p) => `\`${p}\``).join(', ')}`);
321
+ L.push('', '## Tag vocabulary', '');
322
+ if (!r.tags.available)
323
+ L.push('- no tag data: rows carry no tags (light mode over a tagless mirror) — refresh or pass list-joined rows');
324
+ else {
325
+ L.push('| Tag | Count |', '| --- | --- |');
326
+ for (const t of r.tags.vocabulary)
327
+ L.push(`| ${t.tag} | ${fmt(t.count)} |`);
328
+ }
329
+ L.push('', '## Redirect stubs', '');
330
+ if (!r.redirects.available)
331
+ L.push('- not visible from map rows (bodies unread) — rerun with `deep:true` to count `> Redirect:` stubs');
332
+ else {
333
+ L.push(`- ${fmt(r.redirects.count)} stub(s)`);
334
+ for (const s of r.redirects.stubs)
335
+ L.push(` - \`${s.path}\` (${s.locale}) → ${s.target}`);
336
+ }
337
+ L.push('', '## Section distribution', '');
338
+ L.push('| Section | Paths | Rows |', '| --- | --- | --- |');
339
+ for (const s of r.sections)
340
+ L.push(`| ${s.section} | ${fmt(s.paths)} | ${fmt(s.rows)} |`);
341
+ if (r.freshness !== null) {
342
+ L.push('', '## Freshness (deep)', '');
343
+ L.push(`- scanned ${fmt(r.freshness.scanned)} bodies (${fmt(r.freshness.unreadable)} unreadable) · ` +
344
+ `missing 上次核实 stamp: ${fmt(r.freshness.missingLastVerified.length)} · expired review-by: ${fmt(r.freshness.expiredReviewBy.length)}`);
345
+ for (const m of r.freshness.missingLastVerified)
346
+ L.push(` - stamp missing: \`${m.path}\` (${m.locale}, ${m.genre})`);
347
+ for (const e of r.freshness.expiredReviewBy)
348
+ L.push(` - review overdue: \`${e.path}\` (${e.locale}) since ${e.reviewBy} (${fmt(e.daysExpired)}d)`);
349
+ }
350
+ L.push('', '## Machine-readable JSON', '', '```json', JSON.stringify(r, null, 2), '```', '');
351
+ return L.join('\n');
352
+ }
@@ -21,7 +21,8 @@ export interface ChecklistVerdict {
21
21
  /** Score the full 10-item gate on a draft. `genre` decides items 4-6:
22
22
  * the base gate's item 4 applies to G2 only and items 5-6 to G1 only
23
23
  * (G3/G4 → 'na'); G5 pages get the ledger variants of all three
24
- * (last-verified column, verification commands, table-not-prose). */
24
+ * (last-verified column, verification commands, table-not-prose) and G6
25
+ * pages the how-to variants (goal-titled H1, step triples, freshness rows). */
25
26
  export declare function scoreChecklist(genre: Genre, draft: string): readonly ChecklistVerdict[];
26
27
  /** Whitespace-normalized equality (the strong signal); otherwise trigram
27
28
  * Jaccard similarity ≥ 0.95. Deterministic, diff-based — never prose. */
@@ -198,10 +198,63 @@ function scoreG5Item6(draft) {
198
198
  note: pass ? undefined : `narrative lines (${narrative}) outnumber table lines (${tableLines}) — a ledger is status + tables, not prose`,
199
199
  };
200
200
  }
201
+ // --- G6 how-to scorers (selfReviewChecklist G6 variants of items 4-6) --------
202
+ const G6_GOAL_H1 = /(?:如何|怎么|怎样|how\s+to)/i;
203
+ const G6_STEPS_HEADING = /^#{2,3}\s.*(?:操作步骤|\bsteps\b)/i;
204
+ function stepsSectionOf(draft) {
205
+ const lines = draft.split('\n');
206
+ const start = lines.findIndex((l) => /^#{2,3}\s/.test(l) && G6_STEPS_HEADING.test(l));
207
+ if (start === -1)
208
+ return undefined;
209
+ const end = lines.findIndex((l, i) => i > start && /^#{1,3}\s/.test(l));
210
+ return lines.slice(start + 1, end === -1 ? lines.length : end).join('\n');
211
+ }
212
+ function scoreG6Item4(draft) {
213
+ const h1 = draft.split('\n').find((l) => /^#\s/.test(l)) ?? '';
214
+ const pass = G6_GOAL_H1.test(h1);
215
+ return {
216
+ id: 4,
217
+ verdict: pass ? 'pass' : 'fail',
218
+ note: pass ? undefined : `H1 is not goal-titled ("How to X" / "如何(怎么)做X"): "${h1.replace(/^#\s*/, '').slice(0, 40)}"`,
219
+ };
220
+ }
221
+ function scoreG6Item5(draft) {
222
+ const section = stepsSectionOf(draft);
223
+ if (section === undefined) {
224
+ return { id: 5, verdict: 'fail', note: 'no 操作步骤/Steps section' };
225
+ }
226
+ const numbered = /^\s*1[.、]/m.test(section);
227
+ const expected = /(预期|expected)/i.test(section);
228
+ const failure = /(失败|on failure|fallback)/i.test(section);
229
+ const pass = numbered && expected && failure;
230
+ return {
231
+ id: 5,
232
+ verdict: pass ? 'pass' : 'fail',
233
+ note: pass ? undefined : `steps section lacks ${numbered ? '' : 'numbered items '}${expected ? '' : 'expected-result leg '}${failure ? '' : 'on-failure leg '}`.trim(),
234
+ };
235
+ }
236
+ function scoreG6Item6(draft) {
237
+ const verified = /(上次核实|last verified)/i.test(draft);
238
+ const review = /(复核周期|review[- ]by|cadence)/i.test(draft);
239
+ const pass = verified && review;
240
+ return {
241
+ id: 6,
242
+ verdict: pass ? 'pass' : 'fail',
243
+ note: pass ? undefined : `metadata table lacks ${verified ? '' : 'last-verified (上次核实) '}${review ? '' : 'review-by (复核周期)'}row(s)`,
244
+ };
245
+ }
246
+ /** Items 4-6 genre variants: G5 ledgers and G6 how-tos swap in their own
247
+ * gates; every other genre keeps the base trio. */
248
+ const GENRE_SCORERS = {
249
+ G5: [scoreG5Item4, scoreG5Item5, scoreG5Item6],
250
+ G6: [scoreG6Item4, scoreG6Item5, scoreG6Item6],
251
+ };
252
+ const BASE_SCORERS = [scoreItem4, scoreItem5, scoreItem6];
201
253
  /** Score the full 10-item gate on a draft. `genre` decides items 4-6:
202
254
  * the base gate's item 4 applies to G2 only and items 5-6 to G1 only
203
255
  * (G3/G4 → 'na'); G5 pages get the ledger variants of all three
204
- * (last-verified column, verification commands, table-not-prose). */
256
+ * (last-verified column, verification commands, table-not-prose) and G6
257
+ * pages the how-to variants (goal-titled H1, step triples, freshness rows). */
205
258
  export function scoreChecklist(genre, draft) {
206
259
  const items = selfReviewChecklist(genre);
207
260
  const na = (item) => ({
@@ -214,16 +267,16 @@ export function scoreChecklist(genre, draft) {
214
267
  verdict: 'deferred',
215
268
  note: 'post-write item — score after apply and write into the pilot report',
216
269
  });
217
- const ledger = genre === 'G5';
270
+ const [score4, score5, score6] = GENRE_SCORERS[genre] ?? BASE_SCORERS;
218
271
  return items.map((item) => {
219
272
  const applies = item.appliesTo === 'all' || item.appliesTo.includes(genre);
220
273
  switch (item.id) {
221
274
  case 1: return scoreItem1(draft);
222
275
  case 2: return scoreItem2(draft);
223
276
  case 3: return scoreItem3(draft);
224
- case 4: return applies ? (ledger ? scoreG5Item4(draft) : scoreItem4(draft)) : na(item);
225
- case 5: return applies ? (ledger ? scoreG5Item5(draft) : scoreItem5(draft)) : na(item);
226
- case 6: return applies ? (ledger ? scoreG5Item6(draft) : scoreItem6(draft)) : na(item);
277
+ case 4: return applies ? score4(draft) : na(item);
278
+ case 5: return applies ? score5(draft) : na(item);
279
+ case 6: return applies ? score6(draft) : na(item);
227
280
  case 7: return scoreItem7(draft);
228
281
  case 8: return scoreItem8(draft);
229
282
  default: return deferred(item);