opencode-wiki-historian 0.4.0 → 0.5.1

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 ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Body lint: one fence-aware pass over a page body producing the structural
3
+ * facts every hard gate and surface detector reuses (plan v0.5.0 #6).
4
+ * Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
5
+ * tools lint drafts before they hit the API. This is the module the
6
+ * architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
7
+ * here was optional before, which is exactly how a page with five empty
8
+ * sections shipped for three days claiming `状态: Active`.
9
+ */
10
+ import type { Locale } from './wiki/pages.read.js';
11
+ /** Machine rule keys; the human report renders zh labels from these. */
12
+ export type GateViolation = 'redirect-stub-no-exit' | 'active-with-unfinished-skeleton';
13
+ export interface LintFinding {
14
+ readonly key: 'todo-markers' | 'empty-sections' | 'intro-empty' | 'no-related-pages' | 'no-state-block' | 'h1-mismatch' | 'zh-english-dominant' | 'claims-without-stamp';
15
+ readonly detail: string;
16
+ }
17
+ export interface PageLink {
18
+ /** Normalized wiki path (locale prefix stripped, leading `/` removed, no anchor/query). */
19
+ readonly path: string;
20
+ readonly locale: Locale;
21
+ /** True when the raw target was a same-page anchor (`#…`) — never resolved. */
22
+ readonly anchor: boolean;
23
+ }
24
+ export interface ClaimCounts {
25
+ readonly ports: number;
26
+ readonly paths: number;
27
+ readonly commands: number;
28
+ }
29
+ export interface BodyLint {
30
+ readonly isRedirectStub: boolean;
31
+ readonly redirectTarget: string | null;
32
+ readonly stubHasLink: boolean;
33
+ readonly hasStateBlock: boolean;
34
+ readonly state: 'active' | 'draft' | 'superseded' | 'deprecated' | null;
35
+ readonly todoMarkers: number;
36
+ /** Heading text of sections whose body (before the next heading ≤ level) is empty. */
37
+ readonly emptySections: readonly string[];
38
+ readonly introEmpty: boolean;
39
+ readonly hasRelatedPages: boolean;
40
+ readonly h1: string | null;
41
+ /** All heading spans in document order (twin-parity + structure checks). */
42
+ readonly headings: readonly HeadingSpan[];
43
+ readonly links: readonly PageLink[];
44
+ readonly hasStamp: boolean;
45
+ readonly claims: ClaimCounts;
46
+ readonly claimTotal: number;
47
+ /** CJK chars / non-whitespace chars, zh body only meaningful; 0..1. */
48
+ readonly cjkRatio: number;
49
+ }
50
+ export interface HeadingSpan {
51
+ readonly level: number;
52
+ readonly heading: string;
53
+ /** Subtree body: weighted length until the next heading ≤ level, INCLUDING
54
+ * descendant sections' content (masked+comment-free). */
55
+ readonly bodyChars: number;
56
+ /** Direct body only: weighted length until the next heading at ANY level.
57
+ * Used for the intro ratio so an H1 span never swallows the whole page. */
58
+ readonly directChars: number;
59
+ }
60
+ /** Section headings (##+) whose SUBTREE holds no content. A parent that groups
61
+ * non-empty subsections is an outline container, not an unfinished product;
62
+ * only wholly-empty subtrees indicate an unfilled skeleton section. */
63
+ export declare function emptySectionsOf(headings: readonly HeadingSpan[]): readonly string[];
64
+ export interface LinkScanOpts {
65
+ readonly baseUrl: string;
66
+ }
67
+ /** Extract internal page links (relative wiki paths + same-host absolute URLs). */
68
+ export declare function extractLinks(masked: string, opts: LinkScanOpts): readonly PageLink[];
69
+ export interface LintOpts {
70
+ readonly locale: Locale;
71
+ readonly baseUrl: string;
72
+ /** Page title for the h1-mismatch check. */
73
+ readonly title?: string;
74
+ }
75
+ export declare function lintBody(body: string, opts: LintOpts): BodyLint;
76
+ /** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
77
+ export declare function h1TitleMismatch(lint: BodyLint, title: string | undefined): boolean;
78
+ /** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
79
+ export declare function publishGateViolations(lint: BodyLint): readonly GateViolation[];
package/dist/lint.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Body lint: one fence-aware pass over a page body producing the structural
3
+ * facts every hard gate and surface detector reuses (plan v0.5.0 #6).
4
+ * Pure functions, zero wiki I/O — the deep scanner injects bodies, the write
5
+ * tools lint drafts before they hit the API. This is the module the
6
+ * architecture-page audit (HANDOFF Issue #6) forced into existence: nothing
7
+ * here was optional before, which is exactly how a page with five empty
8
+ * sections shipped for three days claiming `状态: Active`.
9
+ */
10
+ // --- fence + comment masking ----------------------------------------------------
11
+ /** Blank out fenced code-block interiors (keep line structure) so markers,
12
+ * headings and links inside code fences never count (SYN fence rule). */
13
+ function maskFences(body) {
14
+ const lines = body.split('\n');
15
+ let fence = null;
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i];
18
+ const m = /^(`{3,}|~{3,})/.exec(line.trimStart());
19
+ if (fence === null && m !== null) {
20
+ fence = m[1][0];
21
+ continue;
22
+ }
23
+ if (fence !== null) {
24
+ if (line.trimStart().startsWith(fence.repeat(3))) {
25
+ fence = null;
26
+ }
27
+ else {
28
+ lines[i] = '';
29
+ }
30
+ }
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
35
+ // --- redirect stub --------------------------------------------------------------
36
+ const REDIRECT_LINE_RE = /^>\s*Redirect:\s*(\S.*?)\s*$/im;
37
+ function redirectTarget(masked) {
38
+ const m = REDIRECT_LINE_RE.exec(masked);
39
+ return m !== null ? m[1] : null;
40
+ }
41
+ /** Clickable exit (Issue #5.1): a stub only lives if it carries at least one
42
+ * real internal link — a bare `<code>` path or an in-page anchor is a dead
43
+ * end for the reader who lands here. Wrong-target links belong to the
44
+ * dead-link scanner, not this gate. */
45
+ function hasClickableExit(visibleNoAnchors) {
46
+ return visibleNoAnchors > 0;
47
+ }
48
+ // --- status block -----------------------------------------------------------------
49
+ const STATE_LINE_RE = /(?:^|\n)\s*\*{0,2}\s*(?:状态\s*\/\s*Status|状态|Status)\s*\*{0,2}\s*[::]\s*([A-Za-z\u4e00-\u9fff][^\n·|<]*)/i;
50
+ function parseState(maskedNoComments) {
51
+ const m = STATE_LINE_RE.exec(maskedNoComments);
52
+ if (m === null)
53
+ return { has: false, state: null };
54
+ const v = m[1].trim().toLowerCase();
55
+ if (v.startsWith('active'))
56
+ return { has: true, state: 'active' };
57
+ if (v.startsWith('draft'))
58
+ return { has: true, state: 'draft' };
59
+ if (v.startsWith('superseded'))
60
+ return { has: true, state: 'superseded' };
61
+ if (v.startsWith('deprecated'))
62
+ return { has: true, state: 'deprecated' };
63
+ return { has: true, state: null };
64
+ }
65
+ // --- markers, headings, intro -------------------------------------------------------
66
+ const TODO_COMMENT_RE = /(TODO|TBD|PLACEHOLDER|占位)/i;
67
+ const LITERAL_TODO_RE = /\bTODO:/g;
68
+ function countTodoMarkers(masked) {
69
+ let n = 0;
70
+ for (const m of masked.matchAll(COMMENT_RE)) {
71
+ if (TODO_COMMENT_RE.test(m[0]))
72
+ n++;
73
+ }
74
+ // literal TODO: outside comments (comments already masked to '' for this pass)
75
+ const noComments = masked.replace(COMMENT_RE, '');
76
+ n += (noComments.match(LITERAL_TODO_RE) ?? []).length;
77
+ return n;
78
+ }
79
+ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*$/;
80
+ const STATUS_BOILERPLATE_RE = /状态\s*\/\s*Status|日期\s*\/\s*Date|本页回答|This page answers/i;
81
+ /** Parse headings + emptiness over masked, comment-stripped text. Also returns
82
+ * the intro span (before the first heading) for the 导言占比 detector. */
83
+ function parseStructure(masked) {
84
+ const noComments = masked.replace(COMMENT_RE, '');
85
+ const lines = noComments.split('\n');
86
+ const headings = [];
87
+ let h1 = null;
88
+ let intro = 0;
89
+ const stack = [];
90
+ // CJK glyphs carry ~3x the visual weight of a latin char per cell — count
91
+ // weight, not raw chars, so a terse Chinese intro is not flagged empty.
92
+ const weight = (s) => {
93
+ const stripped = s.replace(/\s/g, '');
94
+ const cjk = (stripped.match(CJK_WEIGHT_RE) ?? []).length;
95
+ return cjk * 3 + (stripped.length - cjk);
96
+ };
97
+ const closeTo = (level) => {
98
+ const closing = [];
99
+ for (;;) {
100
+ const s = stack[stack.length - 1];
101
+ if (s === undefined || s.level < level)
102
+ break;
103
+ stack.pop();
104
+ closing.push({ level: s.level, heading: s.heading, bodyChars: s.subtree, directChars: s.direct });
105
+ }
106
+ // inner-first pop order must be flipped back to document order
107
+ headings.push(...closing.reverse());
108
+ };
109
+ for (const line of lines) {
110
+ const m = HEADING_RE.exec(line);
111
+ if (m !== null) {
112
+ const level = m[1].length;
113
+ closeTo(level);
114
+ if (level === 1 && h1 === null)
115
+ h1 = m[2].trim();
116
+ stack.push({ level, heading: m[2].trim(), direct: 0, subtree: 0 });
117
+ continue;
118
+ }
119
+ if (STATUS_BOILERPLATE_RE.test(line))
120
+ continue;
121
+ const w = weight(line);
122
+ if (stack.length === 0) {
123
+ intro += w;
124
+ continue;
125
+ }
126
+ for (const s of stack)
127
+ s.subtree += w;
128
+ stack[stack.length - 1].direct += w;
129
+ }
130
+ closeTo(1);
131
+ // The intro is the H1 section's DIRECT span (real pages all open with
132
+ // `# 标题`); the pre-heading accumulator only carries weight on heading-less bodies.
133
+ const firstH1 = headings.find((h) => h.level === 1);
134
+ const introWeight = firstH1 !== undefined ? firstH1.directChars : intro;
135
+ return { headings, introEmpty: introWeight < 40, h1 };
136
+ }
137
+ /** Section headings (##+) whose SUBTREE holds no content. A parent that groups
138
+ * non-empty subsections is an outline container, not an unfinished product;
139
+ * only wholly-empty subtrees indicate an unfilled skeleton section. */
140
+ export function emptySectionsOf(headings) {
141
+ return headings.filter((h) => h.level >= 2 && h.bodyChars === 0).map((h) => h.heading);
142
+ }
143
+ // --- links -----------------------------------------------------------------------
144
+ const LINK_MD_RE = /\]\(([^)\s]+)[^)]*\)/g;
145
+ const LINK_HREF_RE = /href="([^"]+)"/gi;
146
+ /** Extract internal page links (relative wiki paths + same-host absolute URLs). */
147
+ export function extractLinks(masked, opts) {
148
+ const out = [];
149
+ const host = opts.baseUrl.replace(/\/+$/, '');
150
+ const seen = new Set();
151
+ const push = (raw) => {
152
+ let target = raw.trim();
153
+ if (target === '' || target.startsWith('#')) {
154
+ if (target.startsWith('#'))
155
+ out.push({ path: target.slice(1), locale: 'en', anchor: true });
156
+ return;
157
+ }
158
+ if (/^(mailto:|javascript:)/i.test(target))
159
+ return;
160
+ let locale = 'en';
161
+ if (/^https?:\/\//i.test(target)) {
162
+ if (!target.toLowerCase().startsWith(host.toLowerCase()))
163
+ return; // external
164
+ target = target.slice(host.length);
165
+ }
166
+ if (/\.(png|jpe?g|gif|svg|webp|pdf|zip|css|js)\b/i.test(target))
167
+ return;
168
+ target = target.split('#')[0];
169
+ target = target.split('?')[0];
170
+ target = target.replace(/^\/+/, '');
171
+ if (target === '')
172
+ return;
173
+ if (target.startsWith('zh/')) {
174
+ locale = 'zh';
175
+ target = target.slice(3);
176
+ }
177
+ else if (target.startsWith('en/')) {
178
+ target = target.slice(3);
179
+ }
180
+ const key = `${locale}\u0000${target}`;
181
+ if (seen.has(key))
182
+ return;
183
+ seen.add(key);
184
+ out.push({ path: target, locale, anchor: false });
185
+ };
186
+ for (const re of [LINK_MD_RE, LINK_HREF_RE]) {
187
+ re.lastIndex = 0;
188
+ let m;
189
+ while ((m = re.exec(masked)) !== null)
190
+ push(m[1] ?? '');
191
+ }
192
+ return out;
193
+ }
194
+ // --- claims + stamps + language -----------------------------------------------------
195
+ const STAMP_RE = /上次核实|上次验证|last verified/i;
196
+ const PORT_RE = /:\d{4,5}\b/g;
197
+ const FS_PATH_RE = /\/(?:opt|var|etc|usr|srv)\/[\w./-]+/g;
198
+ const CMD_RE = /\b(?:systemctl|journalctl|docker|curl|nc|wget|iptables|nginx|caddy)\b/gi;
199
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
200
+ const CJK_WEIGHT_RE = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff]/g;
201
+ function distinct(text, re) {
202
+ return new Set(text.match(re) ?? []).size;
203
+ }
204
+ // --- main entry ---------------------------------------------------------------------
205
+ export function lintBody(body, opts) {
206
+ const masked = maskFences(body);
207
+ const visible = masked.replace(COMMENT_RE, '');
208
+ const target = redirectTarget(masked);
209
+ const isRedirectStub = /^\s*>\s*Redirect:/im.test(masked);
210
+ const { headings, introEmpty, h1 } = parseStructure(masked);
211
+ const links = extractLinks(visible, { baseUrl: opts.baseUrl }).filter((l) => !l.anchor);
212
+ const nonSpace = (visible.match(/\S/g) ?? []).length;
213
+ const cjk = (visible.match(CJK_RE) ?? []).length;
214
+ const claims = {
215
+ ports: distinct(visible, PORT_RE),
216
+ paths: distinct(visible, FS_PATH_RE),
217
+ commands: distinct(visible, CMD_RE),
218
+ };
219
+ const stateInfo = parseState(visible);
220
+ return {
221
+ isRedirectStub,
222
+ redirectTarget: target,
223
+ stubHasLink: !isRedirectStub || hasClickableExit(links.length),
224
+ hasStateBlock: stateInfo.has,
225
+ state: stateInfo.state,
226
+ todoMarkers: countTodoMarkers(masked),
227
+ emptySections: emptySectionsOf(headings),
228
+ introEmpty,
229
+ hasRelatedPages: /^#{1,6}\s+.*(相关页面|Related Pages)/im.test(masked),
230
+ h1,
231
+ headings,
232
+ links,
233
+ hasStamp: STAMP_RE.test(visible),
234
+ claims,
235
+ claimTotal: claims.ports + claims.paths + claims.commands,
236
+ cjkRatio: nonSpace === 0 ? 0 : cjk / nonSpace,
237
+ };
238
+ }
239
+ /** Structural h1≠title flag (kept out of BodyLint's hot fields on purpose). */
240
+ export function h1TitleMismatch(lint, title) {
241
+ if (title === undefined || lint.h1 === null)
242
+ return false;
243
+ return lint.h1.trim().toLowerCase() !== title.trim().toLowerCase();
244
+ }
245
+ /** The two hard publish-gate rules (shared.ts refuses the write on a hit). */
246
+ export function publishGateViolations(lint) {
247
+ const out = [];
248
+ if (lint.isRedirectStub && !lint.stubHasLink)
249
+ out.push('redirect-stub-no-exit');
250
+ if (lint.state === 'active' && (lint.todoMarkers > 0 || lint.emptySections.length > 0)) {
251
+ out.push('active-with-unfinished-skeleton');
252
+ }
253
+ return out;
254
+ }
@@ -91,7 +91,7 @@ export interface MaintainReport {
91
91
  childPath: string;
92
92
  }[];
93
93
  };
94
- readonly rootOrphans: readonly {
94
+ readonly flatRootPages: readonly {
95
95
  section: string;
96
96
  paths: readonly string[];
97
97
  }[];
@@ -114,7 +114,7 @@ export interface MaintainReport {
114
114
  }[];
115
115
  readonly freshness: FreshnessScan | null;
116
116
  }
117
- export declare const MAINTAIN_SCHEMA = "historian.maintain.v1";
117
+ export declare const MAINTAIN_SCHEMA = "historian.maintain.v2";
118
118
  /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
119
119
  export declare const DUP_TITLE_THRESHOLD = 0.75;
120
120
  export declare function buildMaintainReport(input: MaintainInput, opts?: MaintainOptions): Promise<MaintainReport>;
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.v1';
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;
@@ -58,8 +58,11 @@ function jaccard(a, b) {
58
58
  function cmpStr(a, b) {
59
59
  return a < b ? -1 : a > b ? 1 : 0;
60
60
  }
61
+ // bold-merge deliberately leaves (重定向)/(redirect)-suffixed stubs beside their
62
+ // live twins; clustering those pairs reports non-defects and buries true near-dupes.
63
+ const STUB_TITLE_RE = /[((]\s*(?:重定向|redirect)\s*[))]\s*$/i;
61
64
  function findDuplicates(rows) {
62
- const units = rows.map((r) => ({
65
+ const units = rows.filter((r) => !STUB_TITLE_RE.test(r.title)).map((r) => ({
63
66
  path: r.path,
64
67
  title: r.title,
65
68
  grams: titleGrams(normalize(r.title).toLowerCase()),
@@ -147,7 +150,7 @@ function singleChildDirs(paths) {
147
150
  }
148
151
  return out;
149
152
  }
150
- function rootOrphans(paths) {
153
+ function flatRootPages(paths) {
151
154
  const bySection = new Map();
152
155
  for (const path of paths) {
153
156
  if (path.split('/').length !== 2)
@@ -270,7 +273,7 @@ export async function buildMaintainReport(input, opts = {}) {
270
273
  duplicates: { threshold: DUP_TITLE_THRESHOLD, clusters: findDuplicates(kept) },
271
274
  staleness: { topN, oldest: staleness(kept, now, topN) },
272
275
  diffusion: { singleChildDirs: singleChildDirs(paths) },
273
- rootOrphans: rootOrphans(paths),
276
+ flatRootPages: flatRootPages(paths),
274
277
  tags: tagVocab(kept),
275
278
  redirects,
276
279
  sections: sectionDist(kept),
@@ -313,10 +316,10 @@ export function renderMaintainMarkdown(r) {
313
316
  L.push(' - none');
314
317
  for (const d of r.diffusion.singleChildDirs)
315
318
  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)
319
+ L.push('- flat root pages per section (depth-2 listing, shelving hint — NOT inbound analysis; true orphans = surface deep links.orphanPages):');
320
+ if (r.flatRootPages.length === 0)
318
321
  L.push(' - none');
319
- for (const o of r.rootOrphans)
322
+ for (const o of r.flatRootPages)
320
323
  L.push(` - \`${o.section}/\` (${fmt(o.paths.length)}): ${o.paths.map((p) => `\`${p}\``).join(', ')}`);
321
324
  L.push('', '## Tag vocabulary', '');
322
325
  if (!r.tags.available)
@@ -0,0 +1,146 @@
1
+ /**
2
+ * src/surface.ts — human-interface & content-hygiene detectors (V6.1).
3
+ *
4
+ * maintain.ts owns per-page G5/G6 freshness; this module owns the WIKI-WIDE
5
+ * structural health of the human surface (HANDOFF issues #1-#6): navigation
6
+ * hygiene, map-vs-live coverage, redirect-stub reachability, broken / stacked /
7
+ * index-less links, orphans, twin divergence, zh-first language, unfinished
8
+ * skeletons and claim ledgers that beg re-verification.
9
+ *
10
+ * Pure functions over rows + live inventory + an injected readBody; the light
11
+ * tier costs nothing beyond the map baseline, the deep tier reads every body
12
+ * once (caller pools the reads).
13
+ */
14
+ import type { MaintainRow } from './maintain.js';
15
+ import type { Locale } from './wiki/pages.read.js';
16
+ import type { NavSnapshot } from './wiki/nav.js';
17
+ export declare const SURFACE_SCHEMA: "historian.surface.v1";
18
+ /** Minimal shape of a live `pages.list` row the surface diff needs. */
19
+ export interface LiveRow {
20
+ readonly path: string;
21
+ readonly locale: Locale;
22
+ readonly isPublished?: boolean;
23
+ readonly isPrivate?: boolean;
24
+ }
25
+ export interface SurfaceInput {
26
+ readonly rows: readonly MaintainRow[];
27
+ readonly generatedAt: string;
28
+ readonly baseUrl: string;
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;
36
+ readonly deep?: boolean;
37
+ readonly readBody?: (path: string, locale: Locale) => Promise<string | null>;
38
+ }
39
+ export interface SurfaceCoverage {
40
+ readonly liveRows: number;
41
+ readonly mapRows: number;
42
+ readonly missingFromMap: {
43
+ readonly path: string;
44
+ readonly locale: Locale;
45
+ }[];
46
+ readonly removedFromLive: number;
47
+ }
48
+ export interface SurfaceNav {
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[];
61
+ readonly sectionLandingMissing: {
62
+ readonly dir: string;
63
+ readonly pagePaths: number;
64
+ }[];
65
+ }
66
+ export interface SurfaceUnfinished {
67
+ readonly path: string;
68
+ readonly locale: Locale;
69
+ readonly title: string;
70
+ readonly todo: number;
71
+ readonly emptySections: readonly string[];
72
+ readonly introEmpty: boolean;
73
+ readonly active: boolean;
74
+ readonly h1Mismatch: boolean;
75
+ }
76
+ export interface SurfaceStub {
77
+ readonly path: string;
78
+ readonly locale: Locale;
79
+ readonly target: string | null;
80
+ readonly clickable: boolean;
81
+ readonly targetLive: boolean;
82
+ }
83
+ export interface SurfaceLinks {
84
+ readonly broken: {
85
+ readonly from: string;
86
+ readonly locale: Locale;
87
+ readonly target: string;
88
+ }[];
89
+ readonly toStubs: {
90
+ readonly from: string;
91
+ readonly locale: Locale;
92
+ readonly target: string;
93
+ }[];
94
+ readonly sameTargetStacks: {
95
+ readonly from: string;
96
+ readonly locale: Locale;
97
+ readonly target: string;
98
+ readonly texts: number;
99
+ }[];
100
+ readonly orphanPages: readonly string[];
101
+ readonly indexMissing: readonly string[];
102
+ }
103
+ export interface SurfaceTwin {
104
+ readonly path: string;
105
+ readonly lenRatio: number;
106
+ readonly sectionCountRatio: number;
107
+ readonly relatedMismatch: boolean;
108
+ readonly divergent: boolean;
109
+ }
110
+ export interface SurfaceDeep {
111
+ readonly unfinished: SurfaceUnfinished[];
112
+ readonly stubs: SurfaceStub[];
113
+ readonly links: SurfaceLinks;
114
+ readonly roleDivergence: {
115
+ readonly path: string;
116
+ readonly stubIn: Locale;
117
+ readonly liveIn: Locale;
118
+ }[];
119
+ readonly twinParity: SurfaceTwin[];
120
+ readonly zhEnglishDominant: {
121
+ readonly path: string;
122
+ readonly cjkRatio: number;
123
+ }[];
124
+ readonly ledgerClaims: {
125
+ readonly path: string;
126
+ readonly locale: Locale;
127
+ readonly genre: string;
128
+ readonly ports: number;
129
+ readonly paths: number;
130
+ readonly commands: number;
131
+ }[];
132
+ }
133
+ export interface SurfaceReport {
134
+ readonly schema: typeof SURFACE_SCHEMA;
135
+ readonly generatedAt: string;
136
+ readonly deep: boolean;
137
+ readonly coverage: SurfaceCoverage | null;
138
+ readonly nav: SurfaceNav;
139
+ readonly tagsEmpty: {
140
+ readonly path: string;
141
+ readonly locale: Locale;
142
+ }[];
143
+ readonly deepReport: SurfaceDeep | null;
144
+ }
145
+ export declare function buildSurfaceReport(input: SurfaceInput): Promise<SurfaceReport>;
146
+ export declare function renderSurfaceMarkdown(r: SurfaceReport): string;