chapterhouse 0.3.14 → 0.3.16

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,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
@@ -0,0 +1,260 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync, utimesSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-lint-${process.pid}`);
7
+ const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ async function loadModules() {
10
+ const nonce = `${Date.now()}-${Math.random()}`;
11
+ const lint = await import(new URL(`./lint.js?case=${nonce}`, import.meta.url).href);
12
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
13
+ return { lint, wikiFs };
14
+ }
15
+ function resetSandbox() {
16
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
17
+ rmSync(sandboxRoot, { recursive: true, force: true });
18
+ }
19
+ test.beforeEach(() => {
20
+ resetSandbox();
21
+ });
22
+ test.after(() => {
23
+ rmSync(sandboxRoot, { recursive: true, force: true });
24
+ });
25
+ function wikiPage(frontmatter, body) {
26
+ return `---\n${frontmatter.join("\n")}\n---\n\n${body.trim()}\n`;
27
+ }
28
+ function setPageAge(relativePath, ageInDays) {
29
+ const stamped = new Date(Date.now() - ageInDays * 24 * 60 * 60 * 1000);
30
+ const fullPath = join(wikiDir, relativePath);
31
+ utimesSync(fullPath, stamped, stamped);
32
+ }
33
+ test("lintWiki reports orphan pages and index entries pointing to missing pages", async () => {
34
+ const { lint, wikiFs } = await loadModules();
35
+ wikiFs.ensureWikiStructure();
36
+ wikiFs.writePage("pages/shared/orphan.md", wikiPage([
37
+ "title: Orphan",
38
+ "summary: Not indexed yet",
39
+ "updated: 2026-05-12",
40
+ "tags: [engineering]",
41
+ ], "# Orphan\n\nThis orphan page has enough descriptive body text to avoid the premature-page lint while still remaining absent from the index."));
42
+ wikiFs.writeIndexFile(`# Wiki Index
43
+
44
+ ## Shared
45
+
46
+ - [Missing](pages/shared/missing.md) — Not on disk
47
+ `);
48
+ const report = lint.lintWiki();
49
+ assert.equal(report.pageCount, 1);
50
+ assert.equal(report.sourceCount, 0);
51
+ assert.deepEqual(report.issues.map((issue) => `${issue.rule}:${issue.severity}:${issue.path ?? "-"}`).sort(), [
52
+ "missing-page:warning:pages/shared/missing.md",
53
+ "orphan-page:warning:pages/shared/orphan.md",
54
+ ]);
55
+ });
56
+ test("renderWikiLintReport preserves the current wiki_lint report format", async () => {
57
+ const { lint, wikiFs } = await loadModules();
58
+ wikiFs.ensureWikiStructure();
59
+ wikiFs.writePage("pages/shared/orphan.md", wikiPage([
60
+ "title: Orphan",
61
+ "summary: Not indexed yet",
62
+ "updated: 2026-05-12",
63
+ "tags: [engineering]",
64
+ ], "# Orphan\n\nThis orphan page has enough descriptive body text to avoid the premature-page lint while still remaining absent from the index."));
65
+ wikiFs.writeIndexFile(`# Wiki Index
66
+
67
+ ## Shared
68
+
69
+ - [Tracked](pages/shared/tracked.md) — Present only in the index
70
+ `);
71
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
72
+ assert.match(rendered, /^Wiki health report \(1 pages, 0 sources\):/);
73
+ assert.match(rendered, /warning\s+\|\s+orphan-page\s+\|\s+pages\/shared\/orphan\.md/);
74
+ assert.match(rendered, /warning\s+\|\s+missing-page\s+\|\s+pages\/shared\/tracked\.md/);
75
+ assert.match(rendered, /\*\*Suggestions\*\*: Look for pages that should link to each other/);
76
+ });
77
+ test("renderWikiLintReport reports the healthy wiki message when no issues are found", async () => {
78
+ const { lint, wikiFs } = await loadModules();
79
+ wikiFs.ensureWikiStructure();
80
+ wikiFs.writePage("pages/shared/indexed.md", wikiPage([
81
+ "title: Indexed",
82
+ "summary: Tracked page",
83
+ "updated: 2026-05-12",
84
+ "tags: [engineering]",
85
+ ], "# Indexed\n\n## Overview\n\nTracked page with enough detail to stay above the premature-page threshold and remain clearly established as a real page in the healthy-wiki case."));
86
+ wikiFs.writeIndexFile(`# Wiki Index
87
+
88
+ ## Shared
89
+
90
+ - [Indexed](pages/shared/indexed.md) — Tracked page
91
+ `);
92
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
93
+ assert.match(rendered, /✅ No issues found\. Index and pages are in sync\./);
94
+ });
95
+ test("lintWiki reports the remaining Phase 1 structural and content rules", async () => {
96
+ const { lint, wikiFs } = await loadModules();
97
+ wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
98
+ - engineering
99
+ - release
100
+
101
+ ## Unused
102
+ - ghost-tag
103
+ `);
104
+ wikiFs.writePage("pages/projects/chapterhouse/feature-ideas.md", wikiPage([
105
+ "title: Chapterhouse ideas",
106
+ "summary: Candidate follow-up work",
107
+ "updated: 2026-05-12",
108
+ "tags: [engineering]",
109
+ ], "# Chapterhouse ideas\n\n## Ideas\n\n- Add a conventions skill."));
110
+ setPageAge("pages/projects/chapterhouse/feature-ideas.md", 91);
111
+ wikiFs.writePage("pages/shared/missing-updated.md", wikiPage([
112
+ "title: Missing updated",
113
+ "summary: This page omits the updated field",
114
+ "tags: [engineering]",
115
+ ], "# Missing updated\n\nThis page forgot its date stamp."));
116
+ wikiFs.writePage("pages/shared/invalid-frontmatter.md", wikiPage([
117
+ "title: Invalid frontmatter",
118
+ "summary: **Not plain text**",
119
+ "tags: [engineering, unknown-tag]",
120
+ ], "# Invalid frontmatter\n\nBody content."));
121
+ wikiFs.writePage("pages/shared/skipped-heading.md", wikiPage([
122
+ "title: Skipped heading",
123
+ "summary: Heading depth should not skip levels",
124
+ "updated: 2026-05-12",
125
+ "tags: [engineering]",
126
+ ], "# Skipped heading\n\n### Too deep"));
127
+ wikiFs.writePage("pages/shared/decisions.md", wikiPage([
128
+ "title: Misfiled decisions",
129
+ "summary: Decisions must live under an entity directory",
130
+ "updated: 2026-05-12",
131
+ "tags: [decision]",
132
+ ], "# Misfiled decisions\n\n## Record\n\nA misplaced decision log."));
133
+ wikiFs.writePage("pages/shared/large-warning.md", wikiPage([
134
+ "title: Large warning",
135
+ "summary: Page size warning threshold coverage",
136
+ "updated: 2026-05-12",
137
+ "tags: [engineering]",
138
+ ], `# Large warning\n\n${Array.from({ length: 301 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
139
+ wikiFs.writePage("pages/shared/large-error.md", wikiPage([
140
+ "title: Large error",
141
+ "summary: Page size error threshold coverage",
142
+ "updated: 2026-05-12",
143
+ "tags: [engineering]",
144
+ ], `# Large error\n\n${Array.from({ length: 801 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
145
+ wikiFs.writePage("pages/shared/tiny.md", wikiPage([
146
+ "title: Tiny page",
147
+ "summary: Tiny pages should be linted as premature",
148
+ "updated: 2026-05-12",
149
+ "tags: [engineering]",
150
+ ], "# Tiny page\n\nTiny."));
151
+ wikiFs.writePage("pages/shared/stub.md", wikiPage([
152
+ "title: Stub page",
153
+ "summary: Stub pages should be flipped once they grow up",
154
+ "autostub: true",
155
+ "tags: [autostub]",
156
+ ], `# Stub page\n\n${Array.from({ length: 12 }, (_, index) => `- fleshed out line ${index + 1}`).join("\n")}`));
157
+ const report = lint.lintWiki();
158
+ const signatures = report.issues.map((issue) => `${issue.rule}:${issue.severity}:${issue.path ?? "-"}`);
159
+ assert.ok(signatures.includes("index-integrity:error:pages/projects/chapterhouse"));
160
+ assert.ok(signatures.includes("stale-page:warning:pages/projects/chapterhouse/feature-ideas.md"));
161
+ assert.ok(signatures.includes("missing-updated:warning:pages/shared/missing-updated.md"));
162
+ assert.ok(signatures.includes("frontmatter-shape:error:pages/shared/invalid-frontmatter.md"));
163
+ assert.ok(signatures.includes("heading-depth:warning:pages/shared/skipped-heading.md"));
164
+ assert.ok(signatures.includes("decision-misfile:error:pages/shared/decisions.md"));
165
+ assert.ok(signatures.includes("page-size:warning:pages/shared/large-warning.md"));
166
+ assert.ok(signatures.includes("page-size:error:pages/shared/large-error.md"));
167
+ assert.ok(signatures.includes("premature-page:info:pages/shared/tiny.md"));
168
+ assert.ok(signatures.includes("autostub-not-flipped:info:pages/shared/stub.md"));
169
+ assert.ok(signatures.includes("dead-taxonomy-entry:info:pages/_meta/taxonomy.md"));
170
+ });
171
+ test("lintWiki exempts decision and conversation pages from staleness and exempts autostubs from selected rules", async () => {
172
+ const { lint, wikiFs } = await loadModules();
173
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", wikiPage([
174
+ "title: Chapterhouse",
175
+ "summary: Overview page required for entity facets",
176
+ "updated: 2026-05-12",
177
+ "tags: [engineering]",
178
+ ], "# Chapterhouse\n\n## Overview\n\nProject overview."));
179
+ wikiFs.writePage("pages/projects/chapterhouse/decisions.md", wikiPage([
180
+ "title: Chapterhouse decisions",
181
+ "summary: Decision logs should never go stale",
182
+ "updated: 2026-05-12",
183
+ "tags: [decision]",
184
+ ], "# Chapterhouse decisions\n\n## Decisions\n\nRecorded decisions."));
185
+ setPageAge("pages/projects/chapterhouse/decisions.md", 365);
186
+ wikiFs.writePage("pages/conversations/2026-01-01.md", wikiPage([
187
+ "title: Conversations on 2026-01-01",
188
+ "summary: Daily conversation summary",
189
+ "updated: 2026-01-01",
190
+ "tags: [engineering]",
191
+ ], "# Conversations on 2026-01-01\n\nSummary body."));
192
+ setPageAge("pages/conversations/2026-01-01.md", 365);
193
+ wikiFs.writePage("pages/shared/autostub.md", wikiPage([
194
+ "title: Stub",
195
+ "summary: Placeholder page",
196
+ "autostub: true",
197
+ "tags: [autostub]",
198
+ ], `# Stub\n\n### Placeholder\n\n${Array.from({ length: 900 }, (_, index) => `Line ${index + 1}`).join("\n")}`));
199
+ const report = lint.lintWiki();
200
+ const signatures = report.issues.map((issue) => `${issue.rule}:${issue.path ?? "-"}`);
201
+ assert.equal(signatures.includes("stale-page:pages/projects/chapterhouse/decisions.md"), false);
202
+ assert.equal(signatures.includes("stale-page:pages/conversations/2026-01-01.md"), false);
203
+ assert.equal(signatures.includes("missing-updated:pages/shared/autostub.md"), false);
204
+ assert.equal(signatures.includes("premature-page:pages/shared/autostub.md"), false);
205
+ assert.equal(signatures.includes("heading-depth:pages/shared/autostub.md"), false);
206
+ assert.equal(signatures.includes("page-size:pages/shared/autostub.md"), false);
207
+ });
208
+ test("renderWikiLintReport surfaces contested and low-confidence pages ahead of the general issue list", async () => {
209
+ const { lint, wikiFs } = await loadModules();
210
+ wikiFs.writePage("pages/shared/contested.md", wikiPage([
211
+ "title: Contested page",
212
+ "summary: This page is explicitly contested",
213
+ "updated: 2026-05-12",
214
+ "tags: [engineering]",
215
+ "contested: true",
216
+ ], "# Contested page\n\nThis page needs review."));
217
+ wikiFs.writePage("pages/shared/low-confidence.md", wikiPage([
218
+ "title: Low confidence page",
219
+ "summary: This page has low confidence",
220
+ "updated: 2026-05-12",
221
+ "tags: [engineering]",
222
+ "confidence: low",
223
+ ], "# Low confidence page\n\nThis page needs review."));
224
+ wikiFs.writePage("pages/shared/missing-updated.md", wikiPage([
225
+ "title: Missing updated",
226
+ "summary: A regular issue should come after contested pages",
227
+ "tags: [engineering]",
228
+ ], "# Missing updated\n\nBody."));
229
+ const rendered = lint.renderWikiLintReport(lint.lintWiki());
230
+ const contestedIndex = rendered.indexOf("pages/shared/contested.md");
231
+ const lowConfidenceIndex = rendered.indexOf("pages/shared/low-confidence.md");
232
+ const generalIssueIndex = rendered.indexOf("missing-updated");
233
+ assert.ok(contestedIndex >= 0);
234
+ assert.ok(lowConfidenceIndex >= 0);
235
+ assert.ok(generalIssueIndex >= 0);
236
+ assert.ok(contestedIndex < generalIssueIndex);
237
+ assert.ok(lowConfidenceIndex < generalIssueIndex);
238
+ });
239
+ test("lintWiki reports a missing or unwritable action log", async () => {
240
+ const { lint, wikiFs } = await loadModules();
241
+ wikiFs.ensureWikiStructure();
242
+ wikiFs.writePage("pages/shared/healthy.md", wikiPage([
243
+ "title: Healthy page",
244
+ "summary: Enough content to avoid incidental lint issues",
245
+ "updated: 2026-05-12",
246
+ "tags: [engineering]",
247
+ ], "# Healthy page\n\n## Overview\n\nThis page exists so the action-log rule is isolated from unrelated findings in the test."));
248
+ rmSync(join(wikiDir, "pages", "_meta", "log.md"), { force: true });
249
+ let report = lint.lintWiki();
250
+ assert.ok(report.issues.some((issue) => issue.rule === "action-log" &&
251
+ issue.severity === "error" &&
252
+ issue.path === "pages/_meta/log.md"));
253
+ rmSync(join(wikiDir, "pages", "_meta"), { recursive: true, force: true });
254
+ mkdirSync(join(wikiDir, "pages", "_meta", "log.md"), { recursive: true });
255
+ report = lint.lintWiki();
256
+ assert.ok(report.issues.some((issue) => issue.rule === "action-log" &&
257
+ issue.severity === "error" &&
258
+ issue.path === "pages/_meta/log.md"));
259
+ });
260
+ //# sourceMappingURL=lint.test.js.map