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.
- package/dist/config.js +1 -1
- package/dist/copilot/agents.js +2 -0
- package/dist/copilot/agents.test.js +22 -0
- package/dist/copilot/skills.test.js +10 -0
- package/dist/copilot/system-message.js +2 -0
- package/dist/copilot/system-message.test.js +11 -0
- package/dist/copilot/tools.js +18 -29
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +3 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +14 -32
- package/dist/wiki/index-manager.test.js +3 -3
- package/dist/wiki/lint.js +424 -0
- package/dist/wiki/lint.test.js +260 -0
- package/dist/wiki/log-manager.js +52 -9
- package/dist/wiki/log-manager.test.js +47 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/dist/wiki/topic-structure.js +5 -1
- package/dist/wiki/topic-structure.test.js +1 -0
- package/package.json +1 -1
- package/skills/wiki-conventions/SKILL.md +145 -0
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
|
@@ -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 =
|
|
115
|
-
const title =
|
|
116
|
-
const tags =
|
|
117
|
-
const updated =
|
|
118
|
-
//
|
|
119
|
-
// non-heading content line
|
|
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
|
|
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:
|
|
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
|