opencode-wiki-historian 0.5.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.
@@ -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)
package/dist/surface.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
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 machineSections: readonly string[];
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
@@ -16,6 +16,9 @@ import { lintBody } from './lint.js';
16
16
  import { classifyGenre } from './templates/genres.js';
17
17
  export const SURFACE_SCHEMA = 'historian.surface.v1';
18
18
  const MACHINE_SEG_RE = /^_/;
19
+ // nav targets carry a leading slash and may pre-pend the locale segment
20
+ // (/zh/_meta/x) — machine check runs on the first real path segment.
21
+ const MACHINE_TARGET_RE = /^\/(?:(?:en|zh)\/)?_[^/]+/;
19
22
  const ROOT_EXEMPT = new Set(['home', 'wiki-index']);
20
23
  const visible = (r) => r.isPublished !== false && r.isPrivate !== true;
21
24
  const rowKey = (path, locale) => `${locale}\u0000${path}`;
@@ -23,15 +26,24 @@ function isFrontPath(path) {
23
26
  return !isInternalPath(path) && !path.startsWith('_sandbox/') && !path.startsWith('_data/');
24
27
  }
25
28
  // --- light tier ---------------------------------------------------------------
26
- function buildNav(rows, live) {
29
+ function buildNav(rows, live, nav) {
27
30
  const paths = new Set();
28
31
  for (const r of rows)
29
32
  paths.add(r.path);
30
33
  for (const r of live ?? [])
31
34
  paths.add(r.path);
32
- const machineSections = [...new Set([...paths].map((p) => p.split('/')[0]))]
35
+ const machinePaths = [...new Set([...paths].map((p) => p.split('/')[0]))]
33
36
  .filter((s) => MACHINE_SEG_RE.test(s))
34
37
  .sort();
38
+ const mode = nav?.mode ?? null;
39
+ const machineLinks = [];
40
+ for (const t of nav?.trees ?? []) {
41
+ for (const it of t.items) {
42
+ if (MACHINE_TARGET_RE.test(it.target)) {
43
+ machineLinks.push({ locale: t.locale, label: it.label, target: it.target });
44
+ }
45
+ }
46
+ }
35
47
  const perDir = new Map();
36
48
  for (const p of paths) {
37
49
  const seg = p.split('/');
@@ -43,7 +55,14 @@ function buildNav(rows, live) {
43
55
  .filter(([dir, n]) => n >= 2 && !paths.has(dir))
44
56
  .map(([dir, pagePaths]) => ({ dir, pagePaths }))
45
57
  .sort((a, b) => b.pagePaths - a.pagePaths || a.dir.localeCompare(b.dir));
46
- return { machineSections, sectionLandingMissing };
58
+ return {
59
+ available: nav != null,
60
+ mode,
61
+ filesystemExposed: mode === 'DYNAMIC' || mode === 'MIXED',
62
+ machineLinks,
63
+ machinePaths,
64
+ sectionLandingMissing,
65
+ };
47
66
  }
48
67
  function buildCoverage(rows, live) {
49
68
  if (live === undefined)
@@ -269,7 +288,7 @@ export async function buildSurfaceReport(input) {
269
288
  generatedAt: input.generatedAt,
270
289
  deep: input.deep === true,
271
290
  coverage: buildCoverage(input.rows, input.liveInventory),
272
- nav: buildNav(input.rows, input.liveInventory),
291
+ nav: buildNav(input.rows, input.liveInventory, input.nav),
273
292
  tagsEmpty: input.rows
274
293
  .filter((r) => isFrontPath(r.path) && (r.tags?.length ?? 0) === 0)
275
294
  .map((r) => ({ path: r.path, locale: r.locale }))
@@ -291,8 +310,21 @@ export function renderSurfaceMarkdown(r) {
291
310
  for (const m of cap(r.coverage.missingFromMap, 40))
292
311
  L.push(` - \`${m.locale}/${m.path}\``);
293
312
  }
294
- L.push('', '## 导航 Nav(动态侧栏镜像)', '');
295
- L.push(`- 机器命名空间暴露 machineSections: ${r.nav.machineSections.map((s) => `\`${s}/\``).join(' ') || 'none'}`);
313
+ L.push('', '## 导航 Nav(真相 = 实时导航树,非页面树推断)', '');
314
+ if (!r.nav.available) {
315
+ L.push('- ⚠ 导航树不可读(nav.available=false)— 机器段暴露无法核验,请检查 token 的导航读取权限');
316
+ }
317
+ else {
318
+ L.push(`- mode: \`${r.nav.mode}\` · 文件系统暴露 filesystemExposed: ${r.nav.filesystemExposed ? '⚠ 是 — DYNAMIC/MIXED 会把页面树镜像回侧栏(Issue #1 复发)' : '否'}`);
319
+ if (r.nav.machineLinks.length > 0) {
320
+ L.push(`- ⚠ 导航树内机器段链接 machineLinks (${r.nav.machineLinks.length}):`);
321
+ for (const m of cap(r.nav.machineLinks, 20))
322
+ L.push(` - [${m.locale}] ${m.label} → \`${m.target}\``);
323
+ }
324
+ else {
325
+ L.push(`- 导航树内机器段链接: none ✓(页面树存档段 ${r.nav.machinePaths.map((s) => `\`${s}/\``).join(' ') || '—'} 属设计内,仅备查)`);
326
+ }
327
+ }
296
328
  if (r.nav.sectionLandingMissing.length > 0) {
297
329
  L.push(`- 落地页缺失 sectionLandingMissing(面包屑 404 / 空目录页):`);
298
330
  for (const s of r.nav.sectionLandingMissing)
@@ -12,6 +12,7 @@ import { buildMaintainReport, renderMaintainMarkdown } from '../maintain.js';
12
12
  import { buildSurfaceReport, renderSurfaceMarkdown } from '../surface.js';
13
13
  import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
14
14
  import { listPages, readPage } from '../wiki/pages.read.js';
15
+ import { readPrimaryNav } from '../wiki/nav.js';
15
16
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
16
17
  const s = tool.schema;
17
18
  const TRANSLATE_ARGS = {
@@ -102,17 +103,19 @@ async function runMaintain(deps, mapDeps, snapshot, deep) {
102
103
  }
103
104
  : undefined;
104
105
  const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
106
+ const nav = await readPrimaryNav(client);
105
107
  const surface = await buildSurfaceReport({
106
108
  rows,
107
109
  generatedAt: report.generatedAt,
108
110
  baseUrl: deps.options.baseUrl,
109
111
  liveInventory,
112
+ nav,
110
113
  deep,
111
114
  readBody,
112
115
  });
113
116
  return {
114
117
  action: 'maintain',
115
- schema: 'historian.maintain.v2',
118
+ schema: 'historian.maintain.v3',
116
119
  deep: report.deep,
117
120
  generatedAt: report.generatedAt,
118
121
  rowCount: report.rowCount,
@@ -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>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "opencode plugin that manages a wiki.js knowledge base with bilingual pages, genre templates, and migration tooling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.v2`) |
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`(`_*` 机器命名空间暴露于侧栏 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
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 阈值) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
337
+ | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值;「(重定向)」存根不参与聚类) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
338
338
  | `staleness.oldest` | 最陈旧页 | mark/refresh:G5 卡重新核实或标记 stale,不静默覆盖 |
339
- | `rootOrphans` / `diffusion.singleChildDirs` | 顶级孤儿 / 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根 |
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.machineSections`(surface) | `_*` 机器命名空间进侧栏 | 导航树手工策划,只挂主题章节 |
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) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |