chapterhouse 0.3.15 → 0.3.17

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.
@@ -5,9 +5,11 @@ import { existsSync, statSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { WIKI_DIR } from "../paths.js";
7
7
  import { readIndexFile, writeIndexFile, listPages, readPage } from "./fs.js";
8
+ import { parseWikiFrontmatter } from "./frontmatter.js";
8
9
  import { normalizeWikiPath } from "./path-utils.js";
9
10
  import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
10
11
  const INDEX_PATH = join(WIKI_DIR, "index.md");
12
+ const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
11
13
  // mtime-based cache so per-message context injection doesn't re-parse on every turn.
12
14
  let cache;
13
15
  function invalidateCache() {
@@ -73,7 +75,7 @@ export function parseIndex() {
73
75
  }
74
76
  // Self-heal: if index is empty/corrupted but pages exist on disk, rebuild from disk.
75
77
  if (entries.length === 0) {
76
- const pages = listPages();
78
+ const pages = listPages().filter((path) => !isActionLogPage(path));
77
79
  if (pages.length > 0) {
78
80
  const rebuilt = rebuildIndexFromPages();
79
81
  cache = { mtimeMs, size, entries: rebuilt };
@@ -83,43 +85,20 @@ export function parseIndex() {
83
85
  cache = { mtimeMs, size, entries };
84
86
  return entries;
85
87
  }
86
- /** Parse YAML frontmatter (very simple — supports key: value and key: [a, b]). */
87
- function parseFrontmatter(content) {
88
- const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
89
- if (!m)
90
- return {};
91
- const out = {};
92
- for (const line of m[1].split("\n")) {
93
- const idx = line.indexOf(":");
94
- if (idx <= 0)
95
- continue;
96
- const key = line.slice(0, idx).trim();
97
- let value = line.slice(idx + 1).trim();
98
- if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
99
- value = value.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
100
- }
101
- else if (typeof value === "string") {
102
- value = value.replace(/^['"]|['"]$/g, "");
103
- }
104
- out[key] = value;
105
- }
106
- return out;
107
- }
108
88
  /** Build (or refresh) an IndexEntry by reading the page from disk. */
109
89
  export function buildIndexEntryForPage(path, fallback) {
110
90
  const normalizedPath = normalizeWikiPath(path);
111
91
  const content = readPage(normalizedPath);
112
92
  if (!content)
113
93
  return undefined;
114
- const fm = parseFrontmatter(content);
115
- const title = (typeof fm.title === "string" && fm.title) || fallback?.title || basenameTitle(normalizedPath);
116
- const tags = Array.isArray(fm.tags) ? fm.tags : (fallback?.tags ?? []);
117
- const updated = (typeof fm.updated === "string" && fm.updated) || fallback?.updated;
118
- // Summary heuristic: existing summary if provided, else first non-frontmatter
119
- // non-heading content line trimmed to 160 chars.
120
- let summary = fallback?.summary?.trim() || "";
94
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
95
+ const title = fm.title || fallback?.title || basenameTitle(normalizedPath);
96
+ const tags = fm.tags ?? fallback?.tags ?? [];
97
+ const updated = fm.updated || fallback?.updated;
98
+ // Compliant pages treat frontmatter summary as canonical. Legacy pages fall back
99
+ // to the first non-frontmatter, non-heading content line.
100
+ let summary = fm.summary?.trim() || fallback?.summary?.trim() || "";
121
101
  if (!summary) {
122
- const body = content.replace(/^---[\s\S]*?---\s*/, "");
123
102
  for (const raw of body.split("\n")) {
124
103
  const line = raw.trim();
125
104
  if (!line || line.startsWith("#") || line.startsWith("<!--"))
@@ -149,7 +128,7 @@ function basenameTitle(path) {
149
128
  }
150
129
  /** Rebuild every index entry from on-disk pages. Preserves section if known. */
151
130
  export function rebuildIndexFromPages() {
152
- const pages = listPages();
131
+ const pages = listPages().filter((path) => !isActionLogPage(path));
153
132
  const previous = new Map();
154
133
  // Try to keep section assignments by re-parsing the (possibly-corrupted) index without recursion.
155
134
  try {
@@ -288,6 +267,9 @@ function writeIndexInternal(entries) {
288
267
  }
289
268
  writeIndexFile(lines.join("\n"));
290
269
  }
270
+ function isActionLogPage(path) {
271
+ return ACTION_LOG_PAGE_RE.test(path);
272
+ }
291
273
  /** Add or update an entry in the index. Upserts by path. */
292
274
  export function addToIndex(entry) {
293
275
  const normalizedEntry = { ...entry, path: normalizeWikiPath(entry.path) };
@@ -43,14 +43,14 @@ test("parseIndex reads sections, summaries, tags, and updated dates", async () =
43
43
  },
44
44
  ]);
45
45
  });
46
- test("buildIndexEntryForPage derives title, metadata, and a trimmed summary from page content", async () => {
46
+ test("buildIndexEntryForPage treats frontmatter summary as the canonical index summary", async () => {
47
47
  const { indexManager, wikiFs } = await loadModules();
48
- wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
48
+ wikiFs.writePage("pages/shared/runbooks/deploy.md", `---\ntitle: Deploy Runbook\nsummary: Production deployment checklist\ntags: [ops, release]\nupdated: 2026-05-04\n---\n\n# Deploy\n\n${"Deploy carefully ".repeat(20)}\n`);
49
49
  const entry = indexManager.buildIndexEntryForPage("pages/shared/runbooks/deploy.md");
50
50
  assert.deepEqual(entry, {
51
51
  path: "pages/shared/runbooks/deploy.md",
52
52
  title: "Deploy Runbook",
53
- summary: `${("Deploy carefully ".repeat(20)).trim().slice(0, 157)}…`,
53
+ summary: "Production deployment checklist",
54
54
  section: "Knowledge",
55
55
  tags: ["ops", "release"],
56
56
  updated: "2026-05-04",
@@ -0,0 +1,424 @@
1
+ import { accessSync, constants as fsConstants, existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { config } from "../config.js";
4
+ import { WIKI_DIR } from "../paths.js";
5
+ import { parseIndex } from "./index-manager.js";
6
+ import { listPages, listSources, readPage } from "./fs.js";
7
+ import { parseWikiFrontmatter, validateWikiFrontmatter } from "./frontmatter.js";
8
+ import { ACTION_LOG_PATH } from "./log-manager.js";
9
+ import { TAXONOMY_PATH, loadTaxonomy, parseTaxonomyTags } from "./taxonomy.js";
10
+ const DEFAULT_STALE_OVERRIDES = {
11
+ decisions: "never",
12
+ "feature-ideas": 90,
13
+ conversations: "never",
14
+ default: 180,
15
+ };
16
+ const DEFAULT_OPTIONS = {
17
+ requireUpdated: true,
18
+ requireFrontmatter: true,
19
+ disallowSkippedHeadingLevels: true,
20
+ pageWarnLines: 300,
21
+ pageErrorLines: 800,
22
+ prematureBodyChars: 100,
23
+ autostubExtraLines: 10,
24
+ reportDeadTags: true,
25
+ surfaceContestedFirst: true,
26
+ staleOverrides: DEFAULT_STALE_OVERRIDES,
27
+ };
28
+ const SPECIAL_META_PAGE_RE = /^pages\/_meta\/(?:taxonomy|log(?:-\d{4})?)\.md$/;
29
+ const LEGACY_DATE_STAMP_RE = /^(?:Logged|Updated):\s*\d{4}-\d{2}-\d{2}\b/m;
30
+ export function lintWiki(options = {}) {
31
+ const staleOverrides = {
32
+ ...DEFAULT_STALE_OVERRIDES,
33
+ ...(options.staleOverrides ?? {}),
34
+ };
35
+ const resolved = {
36
+ ...DEFAULT_OPTIONS,
37
+ ...options,
38
+ staleOverrides,
39
+ };
40
+ const actionLogIssues = lintActionLog();
41
+ const indexEntries = parseIndex();
42
+ const pages = listPages().filter((path) => path !== ACTION_LOG_PATH).sort();
43
+ const sources = listSources();
44
+ const indexPaths = new Set(indexEntries.map((entry) => entry.path));
45
+ const issues = [...actionLogIssues];
46
+ let allowedTags = [];
47
+ try {
48
+ allowedTags = loadTaxonomy();
49
+ }
50
+ catch (error) {
51
+ issues.push({
52
+ rule: "frontmatter-shape",
53
+ severity: "error",
54
+ path: TAXONOMY_PATH,
55
+ message: error instanceof Error ? error.message : String(error),
56
+ suggestion: "Fix pages/_meta/taxonomy.md so it only contains '## Group' headings and '- tag' bullets.",
57
+ });
58
+ }
59
+ for (const path of pages.filter((page) => !indexPaths.has(page) && !isSpecialMetaPage(page))) {
60
+ issues.push({
61
+ rule: "orphan-page",
62
+ severity: "warning",
63
+ path,
64
+ message: "Page exists on disk but is missing from the wiki index.",
65
+ suggestion: "Rebuild the index or add the page entry explicitly.",
66
+ });
67
+ }
68
+ for (const entry of indexEntries.filter((candidate) => !readPage(candidate.path))) {
69
+ issues.push({
70
+ rule: "missing-page",
71
+ severity: "warning",
72
+ path: entry.path,
73
+ message: `Index entry points to a page that does not exist on disk: ${entry.title}`,
74
+ suggestion: "Delete the stale index entry or restore the page.",
75
+ });
76
+ }
77
+ issues.push(...lintIndexIntegrity(pages));
78
+ for (const path of pages) {
79
+ const content = readPage(path);
80
+ if (!content)
81
+ continue;
82
+ const { parsed, body } = parseWikiFrontmatter(content);
83
+ const autostub = parsed.autostub === true;
84
+ const specialMetaPage = isSpecialMetaPage(path);
85
+ if (resolved.surfaceContestedFirst && (parsed.contested === true || parsed.confidence === "low")) {
86
+ const state = parsed.contested === true && parsed.confidence === "low"
87
+ ? "contested and low confidence"
88
+ : parsed.contested === true
89
+ ? "contested"
90
+ : "low confidence";
91
+ issues.push({
92
+ rule: "contested-review",
93
+ severity: "info",
94
+ path,
95
+ message: `Page is marked ${state} and should be reviewed first.`,
96
+ });
97
+ }
98
+ if (resolved.requireFrontmatter && !specialMetaPage) {
99
+ const validation = validateWikiFrontmatter(content, allowedTags.length > 0 ? { allowedTags } : undefined);
100
+ if (!validation.valid) {
101
+ issues.push({
102
+ rule: "frontmatter-shape",
103
+ severity: "error",
104
+ path,
105
+ message: validation.errors.map((error) => error.message).join(" "),
106
+ suggestion: "Rewrite the page frontmatter to match the canonical wiki shape before the next update.",
107
+ });
108
+ }
109
+ }
110
+ if (resolved.requireUpdated && !autostub && !specialMetaPage && !parsed.updated && !LEGACY_DATE_STAMP_RE.test(body)) {
111
+ issues.push({
112
+ rule: "missing-updated",
113
+ severity: "warning",
114
+ path,
115
+ message: "Page does not declare an updated date stamp.",
116
+ suggestion: "Add 'updated: YYYY-MM-DD' to the frontmatter.",
117
+ });
118
+ }
119
+ const staleThreshold = staleThresholdForPath(path, resolved.staleOverrides);
120
+ if (!autostub && staleThreshold !== "never" && isPageOlderThan(path, staleThreshold)) {
121
+ issues.push({
122
+ rule: "stale-page",
123
+ severity: "warning",
124
+ path,
125
+ message: `Page has not been touched in more than ${staleThreshold} days.`,
126
+ suggestion: "Refresh the page or archive it if the information is no longer active.",
127
+ });
128
+ }
129
+ if (!autostub && !specialMetaPage && resolved.disallowSkippedHeadingLevels) {
130
+ const headingIssue = lintHeadingDepth(path, body);
131
+ if (headingIssue)
132
+ issues.push(headingIssue);
133
+ }
134
+ if (isDecisionMisfile(path)) {
135
+ issues.push({
136
+ rule: "decision-misfile",
137
+ severity: "error",
138
+ path,
139
+ message: "decisions.md must live under an entity directory.",
140
+ suggestion: "Move this page to pages/<entity-category>/<topic>/decisions.md.",
141
+ });
142
+ }
143
+ if (!autostub && !specialMetaPage) {
144
+ const lineCount = content.split("\n").length;
145
+ if (lineCount > resolved.pageErrorLines) {
146
+ issues.push({
147
+ rule: "page-size",
148
+ severity: "error",
149
+ path,
150
+ message: `Page is ${lineCount} lines long, which exceeds the ${resolved.pageErrorLines}-line error threshold.`,
151
+ suggestion: "Split the page into focused sub-pages or archive older material.",
152
+ });
153
+ }
154
+ else if (lineCount > resolved.pageWarnLines) {
155
+ issues.push({
156
+ rule: "page-size",
157
+ severity: "warning",
158
+ path,
159
+ message: `Page is ${lineCount} lines long, which exceeds the ${resolved.pageWarnLines}-line warning threshold.`,
160
+ suggestion: "Consider splitting this page before it becomes unwieldy.",
161
+ });
162
+ }
163
+ }
164
+ if (!autostub && !specialMetaPage && bodyVisibleCharCount(body) <= resolved.prematureBodyChars) {
165
+ issues.push({
166
+ rule: "premature-page",
167
+ severity: "info",
168
+ path,
169
+ message: `Page body is too small to justify its own page (${bodyVisibleCharCount(body)} chars).`,
170
+ suggestion: "Fold the content into a parent page until there is enough material to stand alone.",
171
+ });
172
+ }
173
+ if (autostub && autostubExtraLines(body) > resolved.autostubExtraLines) {
174
+ issues.push({
175
+ rule: "autostub-not-flipped",
176
+ severity: "info",
177
+ path,
178
+ message: "Page has grown past stub size but still declares autostub: true.",
179
+ suggestion: "Remove autostub: true once the page becomes a real page.",
180
+ });
181
+ }
182
+ }
183
+ if (resolved.reportDeadTags) {
184
+ issues.push(...lintDeadTaxonomyEntries(pages));
185
+ }
186
+ return {
187
+ pageCount: pages.length,
188
+ sourceCount: sources.length,
189
+ issues: sortIssues(issues),
190
+ };
191
+ }
192
+ export function renderWikiLintReport(report) {
193
+ const sections = [
194
+ `Wiki health report (${report.pageCount} pages, ${report.sourceCount} sources):`,
195
+ ];
196
+ if (report.issues.length === 0) {
197
+ sections.push("\n✅ No issues found. Index and pages are in sync.");
198
+ sections.push("\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.");
199
+ return sections.join("\n");
200
+ }
201
+ const contested = report.issues.filter((issue) => issue.rule === "contested-review");
202
+ const general = report.issues.filter((issue) => issue.rule !== "contested-review");
203
+ if (contested.length > 0) {
204
+ sections.push("\n**Review first**:");
205
+ for (const issue of contested) {
206
+ sections.push(formatIssueLine(issue));
207
+ }
208
+ }
209
+ if (general.length > 0) {
210
+ sections.push("\n**Issues**:");
211
+ for (const issue of general) {
212
+ sections.push(formatIssueLine(issue));
213
+ }
214
+ }
215
+ sections.push("\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.");
216
+ return sections.join("\n");
217
+ }
218
+ function lintIndexIntegrity(pages) {
219
+ const issues = [];
220
+ const entityCategories = new Set(config.wikiEntityCategories);
221
+ const dirsWithFacets = new Set();
222
+ const dirsWithIndex = new Set();
223
+ for (const path of pages) {
224
+ const match = path.match(/^pages\/([^/]+)\/([^/]+)\/([^/]+)\.md$/);
225
+ if (!match)
226
+ continue;
227
+ const [, category, topic, page] = match;
228
+ if (!entityCategories.has(category))
229
+ continue;
230
+ const dirPath = `pages/${category}/${topic}`;
231
+ if (page === "index") {
232
+ dirsWithIndex.add(dirPath);
233
+ }
234
+ else {
235
+ dirsWithFacets.add(dirPath);
236
+ }
237
+ }
238
+ for (const dirPath of [...dirsWithFacets].sort()) {
239
+ if (!dirsWithIndex.has(dirPath)) {
240
+ issues.push({
241
+ rule: "index-integrity",
242
+ severity: "error",
243
+ path: dirPath,
244
+ message: "Entity directory has facet pages but no index.md overview page.",
245
+ suggestion: `Add ${dirPath}/index.md so the topic has a canonical overview page.`,
246
+ });
247
+ }
248
+ }
249
+ return issues;
250
+ }
251
+ function lintHeadingDepth(path, body) {
252
+ const headings = body
253
+ .split("\n")
254
+ .map((line) => line.match(/^(#{1,6})\s+(.+)$/))
255
+ .filter((match) => match !== null);
256
+ if (headings.length === 0)
257
+ return undefined;
258
+ const firstLevel = headings[0][1].length;
259
+ if (firstLevel !== 1) {
260
+ return {
261
+ rule: "heading-depth",
262
+ severity: "warning",
263
+ path,
264
+ message: "Page headings must start with a single '#' title heading.",
265
+ suggestion: "Start the page with '# Title' before any deeper sections.",
266
+ };
267
+ }
268
+ let previousLevel = firstLevel;
269
+ for (const heading of headings.slice(1)) {
270
+ const level = heading[1].length;
271
+ if (level > previousLevel + 1) {
272
+ return {
273
+ rule: "heading-depth",
274
+ severity: "warning",
275
+ path,
276
+ message: `Heading depth skips from h${previousLevel} to h${level}.`,
277
+ suggestion: "Add the missing intermediate heading level or flatten the structure.",
278
+ };
279
+ }
280
+ previousLevel = level;
281
+ }
282
+ return undefined;
283
+ }
284
+ function lintDeadTaxonomyEntries(pages) {
285
+ const taxonomyContent = readPage(TAXONOMY_PATH);
286
+ if (!taxonomyContent) {
287
+ return [];
288
+ }
289
+ let overrideTags;
290
+ try {
291
+ overrideTags = parseTaxonomyTags(taxonomyContent);
292
+ }
293
+ catch {
294
+ return [];
295
+ }
296
+ const usedTags = new Set();
297
+ for (const path of pages) {
298
+ const content = readPage(path);
299
+ if (!content)
300
+ continue;
301
+ const { parsed } = parseWikiFrontmatter(content);
302
+ for (const tag of parsed.tags ?? []) {
303
+ usedTags.add(tag.toLowerCase());
304
+ }
305
+ }
306
+ return overrideTags
307
+ .filter((tag) => !usedTags.has(tag.toLowerCase()))
308
+ .sort()
309
+ .map((tag) => ({
310
+ rule: "dead-taxonomy-entry",
311
+ severity: "info",
312
+ path: TAXONOMY_PATH,
313
+ message: `Tag '${tag}' is declared in the taxonomy but unused by any page.`,
314
+ suggestion: `Remove '${tag}' from pages/_meta/taxonomy.md or tag a page with it.`,
315
+ }));
316
+ }
317
+ function staleThresholdForPath(path, overrides) {
318
+ if (path.startsWith("pages/conversations/")) {
319
+ return overrides.conversations ?? "never";
320
+ }
321
+ if (path.endsWith("/decisions.md")) {
322
+ return overrides.decisions ?? "never";
323
+ }
324
+ if (path.endsWith("/feature-ideas.md")) {
325
+ return overrides["feature-ideas"] ?? 90;
326
+ }
327
+ return overrides.default ?? 180;
328
+ }
329
+ function isPageOlderThan(path, ageInDays) {
330
+ const stats = statSync(join(WIKI_DIR, path));
331
+ const ageMs = Date.now() - stats.mtime.getTime();
332
+ return ageMs > ageInDays * 24 * 60 * 60 * 1000;
333
+ }
334
+ function lintActionLog() {
335
+ const fullPath = join(WIKI_DIR, ACTION_LOG_PATH);
336
+ if (!existsSync(fullPath)) {
337
+ return [{
338
+ rule: "action-log",
339
+ severity: "error",
340
+ path: ACTION_LOG_PATH,
341
+ message: "The wiki action log is missing.",
342
+ suggestion: "Restore pages/_meta/log.md so wiki operations keep an append-only audit trail.",
343
+ }];
344
+ }
345
+ try {
346
+ const stats = statSync(fullPath);
347
+ if (!stats.isFile()) {
348
+ return [{
349
+ rule: "action-log",
350
+ severity: "error",
351
+ path: ACTION_LOG_PATH,
352
+ message: "The wiki action log path exists but is not a writable file.",
353
+ suggestion: "Replace pages/_meta/log.md with a regular writable Markdown file.",
354
+ }];
355
+ }
356
+ accessSync(fullPath, fsConstants.W_OK);
357
+ return [];
358
+ }
359
+ catch (error) {
360
+ return [{
361
+ rule: "action-log",
362
+ severity: "error",
363
+ path: ACTION_LOG_PATH,
364
+ message: error instanceof Error
365
+ ? `The wiki action log is not writable: ${error.message}`
366
+ : "The wiki action log is not writable.",
367
+ suggestion: "Fix permissions on pages/_meta/log.md so wiki operations can append to it.",
368
+ }];
369
+ }
370
+ }
371
+ function isDecisionMisfile(path) {
372
+ if (!path.endsWith("/decisions.md")) {
373
+ return false;
374
+ }
375
+ return !config.wikiEntityCategories.some((category) => {
376
+ const expectedPrefix = `pages/${category}/`;
377
+ return path.startsWith(expectedPrefix) && path.split("/").length === 4;
378
+ });
379
+ }
380
+ function isSpecialMetaPage(path) {
381
+ return SPECIAL_META_PAGE_RE.test(path);
382
+ }
383
+ function bodyVisibleCharCount(body) {
384
+ const visible = body
385
+ .split("\n")
386
+ .map((line) => line.trim())
387
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
388
+ .join(" ")
389
+ .trim();
390
+ return visible.length;
391
+ }
392
+ function autostubExtraLines(body) {
393
+ const meaningfulLines = body
394
+ .split("\n")
395
+ .map((line) => line.trim())
396
+ .filter(Boolean);
397
+ return Math.max(0, meaningfulLines.length - 1);
398
+ }
399
+ function sortIssues(issues) {
400
+ const severityRank = {
401
+ error: 0,
402
+ warning: 1,
403
+ info: 2,
404
+ };
405
+ return [...issues].sort((left, right) => {
406
+ if (left.rule === "contested-review" && right.rule !== "contested-review")
407
+ return -1;
408
+ if (right.rule === "contested-review" && left.rule !== "contested-review")
409
+ return 1;
410
+ const severityDelta = severityRank[left.severity] - severityRank[right.severity];
411
+ if (severityDelta !== 0)
412
+ return severityDelta;
413
+ const pathDelta = (left.path ?? "").localeCompare(right.path ?? "");
414
+ if (pathDelta !== 0)
415
+ return pathDelta;
416
+ return left.rule.localeCompare(right.rule);
417
+ });
418
+ }
419
+ function formatIssueLine(issue) {
420
+ const path = issue.path ?? "-";
421
+ const suggestion = issue.suggestion ? `\n suggestion: ${issue.suggestion}` : "";
422
+ return `- ${issue.severity} | ${issue.rule} | ${path} | ${issue.message}${suggestion}`;
423
+ }
424
+ //# sourceMappingURL=lint.js.map