chapterhouse 0.3.18 → 0.3.20
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/api/server-runtime.js +0 -16
- package/dist/api/server.js +3 -14
- package/dist/api/server.test.js +0 -25
- package/dist/copilot/orchestrator.js +27 -10
- package/dist/copilot/orchestrator.test.js +72 -0
- package/dist/copilot/skills.test.js +4 -0
- package/dist/copilot/tools.js +32 -0
- package/dist/copilot/tools.wiki.test.js +46 -0
- package/dist/wiki/fix.js +335 -0
- package/dist/wiki/fix.test.js +350 -0
- package/dist/wiki/frontmatter.js +105 -0
- package/dist/wiki/frontmatter.test.js +120 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-Bjaa3b4i.js → index-9We9vWBC.js} +63 -63
- package/web/dist/assets/index-9We9vWBC.js.map +1 -0
- package/web/dist/assets/{index-lvHFM_ut.css → index-DYx2idiH.css} +1 -1
- package/web/dist/index.html +2 -2
- package/skills/squad/SKILL.md +0 -76
- package/web/dist/assets/index-Bjaa3b4i.js.map +0 -1
package/dist/wiki/fix.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { resolveWikiRelativePath } from "../paths.js";
|
|
4
|
+
import { parseWikiFrontmatter } from "./frontmatter.js";
|
|
5
|
+
import { ensureWikiStructure, listPages, readPage, writePage } from "./fs.js";
|
|
6
|
+
import { loadTaxonomy } from "./taxonomy.js";
|
|
7
|
+
const DEFAULT_FIXES = [
|
|
8
|
+
"frontmatter-backfill",
|
|
9
|
+
"tag-normalize",
|
|
10
|
+
"autostub-mark",
|
|
11
|
+
];
|
|
12
|
+
const KNOWN_FIELD_ORDER = [
|
|
13
|
+
"title",
|
|
14
|
+
"summary",
|
|
15
|
+
"updated",
|
|
16
|
+
"tags",
|
|
17
|
+
"autostub",
|
|
18
|
+
"confidence",
|
|
19
|
+
"contested",
|
|
20
|
+
"contradictions",
|
|
21
|
+
"related",
|
|
22
|
+
];
|
|
23
|
+
export function fixWiki(options = {}) {
|
|
24
|
+
ensureWikiStructure();
|
|
25
|
+
const dryRun = options.dryRun ?? true;
|
|
26
|
+
const enabledFixes = new Set(options.fixes?.length ? options.fixes : DEFAULT_FIXES);
|
|
27
|
+
const pathFilter = options.pathGlob ? compileGlob(options.pathGlob) : undefined;
|
|
28
|
+
const allowedTags = enabledFixes.has("tag-normalize") ? loadTaxonomy() : [];
|
|
29
|
+
const canonicalTags = new Map(allowedTags.map((tag) => [normalizeTagKey(tag), tag]));
|
|
30
|
+
const reports = [];
|
|
31
|
+
const diffs = [];
|
|
32
|
+
let changedFiles = 0;
|
|
33
|
+
const pages = listPages()
|
|
34
|
+
.filter(isFixablePage)
|
|
35
|
+
.filter((path) => !pathFilter || pathFilter.test(path))
|
|
36
|
+
.sort();
|
|
37
|
+
for (const path of pages) {
|
|
38
|
+
const content = readPage(path);
|
|
39
|
+
if (!content)
|
|
40
|
+
continue;
|
|
41
|
+
const parsedResult = parseWikiFrontmatter(content);
|
|
42
|
+
if (parsedResult.parsed.metadata.autofix === false) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const nextFrontmatter = cloneFrontmatter(parsedResult.parsed);
|
|
46
|
+
const body = parsedResult.body;
|
|
47
|
+
const changes = [];
|
|
48
|
+
let unknownTagsForFile = [];
|
|
49
|
+
if (enabledFixes.has("frontmatter-backfill")) {
|
|
50
|
+
let addedTitle = false;
|
|
51
|
+
let addedSummary = false;
|
|
52
|
+
let addedUpdated = false;
|
|
53
|
+
let addedAutostub = false;
|
|
54
|
+
if (!nextFrontmatter.title?.trim()) {
|
|
55
|
+
nextFrontmatter.title = inferTitle(path, body);
|
|
56
|
+
addedTitle = true;
|
|
57
|
+
}
|
|
58
|
+
if (!nextFrontmatter.summary?.trim()) {
|
|
59
|
+
const summary = inferSummary(body);
|
|
60
|
+
nextFrontmatter.summary = summary.text;
|
|
61
|
+
addedSummary = true;
|
|
62
|
+
if (summary.shouldMarkAutostub && nextFrontmatter.autostub !== true && nextFrontmatter.autostub === undefined) {
|
|
63
|
+
nextFrontmatter.autostub = true;
|
|
64
|
+
addedAutostub = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!nextFrontmatter.updated?.trim()) {
|
|
68
|
+
nextFrontmatter.updated = formatDate(statSync(resolveWikiRelativePath(path)).mtime);
|
|
69
|
+
addedUpdated = true;
|
|
70
|
+
}
|
|
71
|
+
const details = [
|
|
72
|
+
...(addedTitle ? ["title"] : []),
|
|
73
|
+
...(addedSummary ? ["summary"] : []),
|
|
74
|
+
...(addedUpdated ? ["updated"] : []),
|
|
75
|
+
...(addedAutostub ? ["autostub"] : []),
|
|
76
|
+
];
|
|
77
|
+
if (details.length > 0) {
|
|
78
|
+
changes.push({ rule: "frontmatter-backfill", details });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (enabledFixes.has("tag-normalize") && nextFrontmatter.tags?.length) {
|
|
82
|
+
const normalizedTags = [];
|
|
83
|
+
const details = [];
|
|
84
|
+
const unknownTags = [];
|
|
85
|
+
for (const tag of nextFrontmatter.tags) {
|
|
86
|
+
const normalized = canonicalTags.get(normalizeTagKey(tag));
|
|
87
|
+
if (normalized) {
|
|
88
|
+
normalizedTags.push(normalized);
|
|
89
|
+
if (normalized !== tag) {
|
|
90
|
+
details.push(`${tag} -> ${normalized}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
normalizedTags.push(tag);
|
|
95
|
+
unknownTags.push(tag);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (details.length > 0) {
|
|
99
|
+
nextFrontmatter.tags = normalizedTags;
|
|
100
|
+
changes.push({ rule: "tag-normalize", details });
|
|
101
|
+
}
|
|
102
|
+
unknownTagsForFile = uniqueStrings(unknownTags);
|
|
103
|
+
}
|
|
104
|
+
if (enabledFixes.has("autostub-mark") && nextFrontmatter.autostub !== true && isStubBody(body)) {
|
|
105
|
+
nextFrontmatter.autostub = true;
|
|
106
|
+
changes.push({ rule: "autostub-mark", details: ["autostub"] });
|
|
107
|
+
}
|
|
108
|
+
if (changes.length === 0 && unknownTagsForFile.length === 0) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const fileReport = {
|
|
112
|
+
path,
|
|
113
|
+
changes,
|
|
114
|
+
};
|
|
115
|
+
if (unknownTagsForFile.length > 0) {
|
|
116
|
+
fileReport.unknownTags = unknownTagsForFile;
|
|
117
|
+
}
|
|
118
|
+
reports.push(fileReport);
|
|
119
|
+
if (changes.length === 0) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
changedFiles += 1;
|
|
123
|
+
const nextContent = renderPage(nextFrontmatter, body);
|
|
124
|
+
if (dryRun) {
|
|
125
|
+
diffs.push(renderUnifiedDiff(path, content, nextContent));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
writePage(path, nextContent);
|
|
129
|
+
for (const change of changes) {
|
|
130
|
+
const logType = logTypeForRule(change.rule);
|
|
131
|
+
if (logType) {
|
|
132
|
+
options.logAction?.(logType, path);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
dryRun,
|
|
138
|
+
scannedFiles: pages.length,
|
|
139
|
+
changedFiles,
|
|
140
|
+
files: reports,
|
|
141
|
+
diff: dryRun ? diffs.join("\n") : "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function cloneFrontmatter(frontmatter) {
|
|
145
|
+
return {
|
|
146
|
+
...frontmatter,
|
|
147
|
+
tags: frontmatter.tags ? [...frontmatter.tags] : undefined,
|
|
148
|
+
contradictions: frontmatter.contradictions ? [...frontmatter.contradictions] : undefined,
|
|
149
|
+
related: frontmatter.related ? [...frontmatter.related] : undefined,
|
|
150
|
+
metadata: { ...frontmatter.metadata },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function inferTitle(path, body) {
|
|
154
|
+
const heading = firstHeading(body);
|
|
155
|
+
if (heading) {
|
|
156
|
+
return stripMarkdown(heading);
|
|
157
|
+
}
|
|
158
|
+
return titleFromPath(path);
|
|
159
|
+
}
|
|
160
|
+
function inferSummary(body) {
|
|
161
|
+
const lines = body.split("\n");
|
|
162
|
+
const headingIndex = lines.findIndex((line) => /^#\s+/.test(line.trim()));
|
|
163
|
+
const startIndex = headingIndex >= 0 ? headingIndex + 1 : 0;
|
|
164
|
+
const paragraph = [];
|
|
165
|
+
for (let index = startIndex; index < lines.length; index += 1) {
|
|
166
|
+
const line = lines[index]?.trim() ?? "";
|
|
167
|
+
if (!line) {
|
|
168
|
+
if (paragraph.length > 0)
|
|
169
|
+
break;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (line.startsWith("#")) {
|
|
173
|
+
if (paragraph.length > 0)
|
|
174
|
+
break;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
paragraph.push(line);
|
|
178
|
+
}
|
|
179
|
+
const summary = stripMarkdown(paragraph.join(" ")).trim();
|
|
180
|
+
if (!summary) {
|
|
181
|
+
return {
|
|
182
|
+
text: "(no summary yet)",
|
|
183
|
+
shouldMarkAutostub: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
text: truncate(summary, 200),
|
|
188
|
+
shouldMarkAutostub: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function firstHeading(body) {
|
|
192
|
+
for (const rawLine of body.split("\n")) {
|
|
193
|
+
const match = rawLine.trim().match(/^#\s+(.+)$/);
|
|
194
|
+
if (match) {
|
|
195
|
+
return match[1].trim();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
function titleFromPath(path) {
|
|
201
|
+
const file = basename(path, ".md");
|
|
202
|
+
const stem = file === "index" ? basename(dirname(path)) : file;
|
|
203
|
+
return stem
|
|
204
|
+
.split(/[-_]+/)
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
207
|
+
.join(" ");
|
|
208
|
+
}
|
|
209
|
+
function stripMarkdown(value) {
|
|
210
|
+
return value
|
|
211
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
212
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
213
|
+
.replace(/[`*_~>#]/g, "")
|
|
214
|
+
.replace(/\s+/g, " ")
|
|
215
|
+
.trim();
|
|
216
|
+
}
|
|
217
|
+
function truncate(value, maxLength) {
|
|
218
|
+
if (value.length <= maxLength) {
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
return value.slice(0, maxLength).trimEnd();
|
|
222
|
+
}
|
|
223
|
+
function formatDate(date) {
|
|
224
|
+
return date.toISOString().slice(0, 10);
|
|
225
|
+
}
|
|
226
|
+
function normalizeTagKey(tag) {
|
|
227
|
+
return tag.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
228
|
+
}
|
|
229
|
+
function isStubBody(body) {
|
|
230
|
+
const meaningfulLines = body
|
|
231
|
+
.split("\n")
|
|
232
|
+
.map((line) => line.trim())
|
|
233
|
+
.filter(Boolean);
|
|
234
|
+
return meaningfulLines.length < 10;
|
|
235
|
+
}
|
|
236
|
+
function renderPage(frontmatter, body) {
|
|
237
|
+
const lines = ["---"];
|
|
238
|
+
for (const field of KNOWN_FIELD_ORDER) {
|
|
239
|
+
const value = frontmatter[field];
|
|
240
|
+
if (value === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
lines.push(formatFrontmatterLine(field, value));
|
|
243
|
+
}
|
|
244
|
+
for (const [key, value] of Object.entries(frontmatter.metadata)) {
|
|
245
|
+
lines.push(formatFrontmatterLine(key, value));
|
|
246
|
+
}
|
|
247
|
+
lines.push("---");
|
|
248
|
+
const normalizedBody = body.replace(/^\n+/, "");
|
|
249
|
+
if (!normalizedBody) {
|
|
250
|
+
return `${lines.join("\n")}\n`;
|
|
251
|
+
}
|
|
252
|
+
return `${lines.join("\n")}\n\n${normalizedBody.endsWith("\n") ? normalizedBody : `${normalizedBody}\n`}`;
|
|
253
|
+
}
|
|
254
|
+
function formatFrontmatterLine(key, value) {
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return `${key}: [${value.map(formatInlineValue).join(", ")}]`;
|
|
257
|
+
}
|
|
258
|
+
if (typeof value === "boolean") {
|
|
259
|
+
return `${key}: ${value ? "true" : "false"}`;
|
|
260
|
+
}
|
|
261
|
+
return `${key}: ${formatScalar(value)}`;
|
|
262
|
+
}
|
|
263
|
+
function formatInlineValue(value) {
|
|
264
|
+
return /^[A-Za-z0-9_./()-]+$/.test(value) ? value : formatScalar(value);
|
|
265
|
+
}
|
|
266
|
+
function formatScalar(value) {
|
|
267
|
+
if (value === "" || /[:#[\]{}'",]|^\s|\s$/.test(value)) {
|
|
268
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function uniqueStrings(values) {
|
|
273
|
+
return [...new Set(values)];
|
|
274
|
+
}
|
|
275
|
+
function renderUnifiedDiff(path, before, after) {
|
|
276
|
+
const beforeLines = splitLines(before);
|
|
277
|
+
const afterLines = splitLines(after);
|
|
278
|
+
const beforeCount = beforeLines.length;
|
|
279
|
+
const afterCount = afterLines.length;
|
|
280
|
+
const diffLines = [
|
|
281
|
+
`--- a/${path}`,
|
|
282
|
+
`+++ b/${path}`,
|
|
283
|
+
`@@ -1,${beforeCount} +1,${afterCount} @@`,
|
|
284
|
+
...beforeLines.map((line) => `-${line}`),
|
|
285
|
+
...afterLines.map((line) => `+${line}`),
|
|
286
|
+
];
|
|
287
|
+
return diffLines.join("\n");
|
|
288
|
+
}
|
|
289
|
+
function splitLines(content) {
|
|
290
|
+
return content.endsWith("\n")
|
|
291
|
+
? content.slice(0, -1).split("\n")
|
|
292
|
+
: content.split("\n");
|
|
293
|
+
}
|
|
294
|
+
function compileGlob(pattern) {
|
|
295
|
+
let regex = "^";
|
|
296
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
297
|
+
const char = pattern[index];
|
|
298
|
+
const next = pattern[index + 1];
|
|
299
|
+
if (char === "*" && next === "*") {
|
|
300
|
+
regex += ".*";
|
|
301
|
+
index += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (char === "*") {
|
|
305
|
+
regex += "[^/]*";
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (char === "?") {
|
|
309
|
+
regex += "[^/]";
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
regex += escapeRegex(char);
|
|
313
|
+
}
|
|
314
|
+
regex += "$";
|
|
315
|
+
return new RegExp(regex);
|
|
316
|
+
}
|
|
317
|
+
function escapeRegex(value) {
|
|
318
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
319
|
+
}
|
|
320
|
+
function logTypeForRule(rule) {
|
|
321
|
+
switch (rule) {
|
|
322
|
+
case "frontmatter-backfill":
|
|
323
|
+
return "fix-frontmatter";
|
|
324
|
+
case "tag-normalize":
|
|
325
|
+
return "fix-tags";
|
|
326
|
+
case "autostub-mark":
|
|
327
|
+
return "fix-autostub";
|
|
328
|
+
default:
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function isFixablePage(path) {
|
|
333
|
+
return !path.startsWith("pages/_meta/") && path !== "pages/index.md";
|
|
334
|
+
}
|
|
335
|
+
//# sourceMappingURL=fix.js.map
|
|
@@ -0,0 +1,350 @@
|
|
|
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-fix-${process.pid}`);
|
|
7
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
8
|
+
process.env.CHAPTERHOUSE_AGENT_NAME = "wiki-fix-test-agent";
|
|
9
|
+
async function loadModules() {
|
|
10
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
11
|
+
const fix = await import(new URL(`./fix.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
13
|
+
return { fix, wikiFs };
|
|
14
|
+
}
|
|
15
|
+
function resetSandbox() {
|
|
16
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
17
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
function wikiPath(relativePath) {
|
|
20
|
+
return join(sandboxRoot, ".chapterhouse", "wiki", ...relativePath.split("/"));
|
|
21
|
+
}
|
|
22
|
+
function setMtime(relativePath, isoDate) {
|
|
23
|
+
const timestamp = new Date(`${isoDate}T12:00:00.000Z`);
|
|
24
|
+
utimesSync(wikiPath(relativePath), timestamp, timestamp);
|
|
25
|
+
}
|
|
26
|
+
test.beforeEach(() => {
|
|
27
|
+
resetSandbox();
|
|
28
|
+
});
|
|
29
|
+
test.after(() => {
|
|
30
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
test("fixWiki dry-run previews frontmatter backfills, tag normalization, and autostub marking without writing files", async () => {
|
|
33
|
+
const { fix, wikiFs } = await loadModules();
|
|
34
|
+
wikiFs.ensureWikiStructure();
|
|
35
|
+
wikiFs.writePage("pages/projects/alpha/index.md", `# Alpha Project
|
|
36
|
+
|
|
37
|
+
Alpha project **launch** notes for the team.
|
|
38
|
+
|
|
39
|
+
## Details
|
|
40
|
+
|
|
41
|
+
One
|
|
42
|
+
Two
|
|
43
|
+
Three
|
|
44
|
+
Four
|
|
45
|
+
Five
|
|
46
|
+
Six
|
|
47
|
+
Seven
|
|
48
|
+
Eight
|
|
49
|
+
`);
|
|
50
|
+
wikiFs.writePage("pages/projects/empty/index.md", `# Empty Page
|
|
51
|
+
`);
|
|
52
|
+
wikiFs.writePage("pages/projects/bravo/index.md", `---
|
|
53
|
+
title: Bravo
|
|
54
|
+
summary: Bravo deployment notes
|
|
55
|
+
updated: 2026-05-10
|
|
56
|
+
tags: [Run Book, Mystery]
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Bravo
|
|
60
|
+
|
|
61
|
+
Deployment notes with enough body to avoid the stub marker.
|
|
62
|
+
|
|
63
|
+
## Steps
|
|
64
|
+
|
|
65
|
+
One
|
|
66
|
+
Two
|
|
67
|
+
Three
|
|
68
|
+
Four
|
|
69
|
+
Five
|
|
70
|
+
Six
|
|
71
|
+
Seven
|
|
72
|
+
Eight
|
|
73
|
+
`);
|
|
74
|
+
wikiFs.writePage("pages/projects/stub/index.md", `---
|
|
75
|
+
title: Stub Page
|
|
76
|
+
summary: Tiny page
|
|
77
|
+
updated: 2026-05-10
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# Stub Page
|
|
81
|
+
|
|
82
|
+
Tiny.
|
|
83
|
+
`);
|
|
84
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
|
|
85
|
+
- runbook
|
|
86
|
+
`);
|
|
87
|
+
setMtime("pages/projects/alpha/index.md", "2026-01-02");
|
|
88
|
+
setMtime("pages/projects/empty/index.md", "2026-01-03");
|
|
89
|
+
const beforeAlpha = wikiFs.readPage("pages/projects/alpha/index.md");
|
|
90
|
+
const beforeEmpty = wikiFs.readPage("pages/projects/empty/index.md");
|
|
91
|
+
const beforeBravo = wikiFs.readPage("pages/projects/bravo/index.md");
|
|
92
|
+
const beforeStub = wikiFs.readPage("pages/projects/stub/index.md");
|
|
93
|
+
const report = fix.fixWiki({ dryRun: true });
|
|
94
|
+
assert.equal(report.dryRun, true);
|
|
95
|
+
assert.equal(report.changedFiles, 4);
|
|
96
|
+
const alpha = report.files.find((entry) => entry.path === "pages/projects/alpha/index.md");
|
|
97
|
+
const empty = report.files.find((entry) => entry.path === "pages/projects/empty/index.md");
|
|
98
|
+
const bravo = report.files.find((entry) => entry.path === "pages/projects/bravo/index.md");
|
|
99
|
+
const stub = report.files.find((entry) => entry.path === "pages/projects/stub/index.md");
|
|
100
|
+
assert.deepEqual(alpha?.changes, [
|
|
101
|
+
{ rule: "frontmatter-backfill", details: ["title", "summary", "updated"] },
|
|
102
|
+
]);
|
|
103
|
+
assert.deepEqual(empty?.changes, [
|
|
104
|
+
{ rule: "frontmatter-backfill", details: ["title", "summary", "updated", "autostub"] },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(bravo?.changes, [
|
|
107
|
+
{ rule: "tag-normalize", details: ["Run Book -> runbook"] },
|
|
108
|
+
]);
|
|
109
|
+
assert.deepEqual(bravo?.unknownTags, ["Mystery"]);
|
|
110
|
+
assert.deepEqual(stub?.changes, [
|
|
111
|
+
{ rule: "autostub-mark", details: ["autostub"] },
|
|
112
|
+
]);
|
|
113
|
+
assert.match(report.diff, /--- a\/pages\/projects\/alpha\/index\.md/);
|
|
114
|
+
assert.match(report.diff, /title: Alpha Project/);
|
|
115
|
+
assert.match(report.diff, /summary: Alpha project launch notes for the team\./);
|
|
116
|
+
assert.match(report.diff, /updated: 2026-01-02/);
|
|
117
|
+
assert.match(report.diff, /summary: \(no summary yet\)/);
|
|
118
|
+
assert.match(report.diff, /tags: \[runbook, Mystery\]/);
|
|
119
|
+
assert.match(report.diff, /autostub: true/);
|
|
120
|
+
assert.equal(wikiFs.readPage("pages/projects/alpha/index.md"), beforeAlpha);
|
|
121
|
+
assert.equal(wikiFs.readPage("pages/projects/empty/index.md"), beforeEmpty);
|
|
122
|
+
assert.equal(wikiFs.readPage("pages/projects/bravo/index.md"), beforeBravo);
|
|
123
|
+
assert.equal(wikiFs.readPage("pages/projects/stub/index.md"), beforeStub);
|
|
124
|
+
});
|
|
125
|
+
test("fixWiki applies changes, logs each applied rule, and is idempotent on the second run", async () => {
|
|
126
|
+
const { fix, wikiFs } = await loadModules();
|
|
127
|
+
wikiFs.ensureWikiStructure();
|
|
128
|
+
wikiFs.writePage("pages/projects/alpha/index.md", `# Alpha Project
|
|
129
|
+
|
|
130
|
+
Alpha project launch notes for the team.
|
|
131
|
+
|
|
132
|
+
## Details
|
|
133
|
+
|
|
134
|
+
One
|
|
135
|
+
Two
|
|
136
|
+
Three
|
|
137
|
+
Four
|
|
138
|
+
Five
|
|
139
|
+
Six
|
|
140
|
+
Seven
|
|
141
|
+
Eight
|
|
142
|
+
`);
|
|
143
|
+
wikiFs.writePage("pages/projects/bravo/index.md", `---
|
|
144
|
+
title: Bravo
|
|
145
|
+
summary: Bravo deployment notes
|
|
146
|
+
updated: 2026-05-10
|
|
147
|
+
tags: [Run Book]
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
# Bravo
|
|
151
|
+
|
|
152
|
+
Deployment notes with enough body to avoid the stub marker.
|
|
153
|
+
|
|
154
|
+
## Steps
|
|
155
|
+
|
|
156
|
+
One
|
|
157
|
+
Two
|
|
158
|
+
Three
|
|
159
|
+
Four
|
|
160
|
+
Five
|
|
161
|
+
Six
|
|
162
|
+
Seven
|
|
163
|
+
Eight
|
|
164
|
+
`);
|
|
165
|
+
wikiFs.writePage("pages/projects/stub/index.md", `---
|
|
166
|
+
title: Stub Page
|
|
167
|
+
summary: Tiny page
|
|
168
|
+
updated: 2026-05-10
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
# Stub Page
|
|
172
|
+
|
|
173
|
+
Tiny.
|
|
174
|
+
`);
|
|
175
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
|
|
176
|
+
- runbook
|
|
177
|
+
`);
|
|
178
|
+
setMtime("pages/projects/alpha/index.md", "2026-01-02");
|
|
179
|
+
const logEntries = [];
|
|
180
|
+
const firstReport = fix.fixWiki({
|
|
181
|
+
dryRun: false,
|
|
182
|
+
logAction: (type, path) => logEntries.push(`${type}:${path}`),
|
|
183
|
+
});
|
|
184
|
+
assert.equal(firstReport.dryRun, false);
|
|
185
|
+
assert.equal(firstReport.changedFiles, 3);
|
|
186
|
+
assert.equal(firstReport.diff, "");
|
|
187
|
+
assert.match(wikiFs.readPage("pages/projects/alpha/index.md") ?? "", /title: Alpha Project/);
|
|
188
|
+
assert.match(wikiFs.readPage("pages/projects/alpha/index.md") ?? "", /updated: 2026-01-02/);
|
|
189
|
+
assert.match(wikiFs.readPage("pages/projects/bravo/index.md") ?? "", /tags: \[runbook\]/);
|
|
190
|
+
assert.match(wikiFs.readPage("pages/projects/stub/index.md") ?? "", /autostub: true/);
|
|
191
|
+
assert.deepEqual(logEntries, [
|
|
192
|
+
"fix-frontmatter:pages/projects/alpha/index.md",
|
|
193
|
+
"fix-tags:pages/projects/bravo/index.md",
|
|
194
|
+
"fix-autostub:pages/projects/stub/index.md",
|
|
195
|
+
]);
|
|
196
|
+
const secondReport = fix.fixWiki({
|
|
197
|
+
dryRun: false,
|
|
198
|
+
logAction: (type, path) => logEntries.push(`${type}:${path}`),
|
|
199
|
+
});
|
|
200
|
+
assert.equal(secondReport.changedFiles, 0);
|
|
201
|
+
assert.equal(secondReport.diff, "");
|
|
202
|
+
assert.deepEqual(logEntries, [
|
|
203
|
+
"fix-frontmatter:pages/projects/alpha/index.md",
|
|
204
|
+
"fix-tags:pages/projects/bravo/index.md",
|
|
205
|
+
"fix-autostub:pages/projects/stub/index.md",
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
test("fixWiki respects fix toggles, path globs, autofix false, and pages/_meta skips", async () => {
|
|
209
|
+
const { fix, wikiFs } = await loadModules();
|
|
210
|
+
wikiFs.ensureWikiStructure();
|
|
211
|
+
wikiFs.writePage("pages/projects/match/index.md", `---
|
|
212
|
+
title: Match
|
|
213
|
+
summary: Match notes
|
|
214
|
+
updated: 2026-05-10
|
|
215
|
+
tags: [Run Book]
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
# Match
|
|
219
|
+
|
|
220
|
+
Long enough body.
|
|
221
|
+
|
|
222
|
+
## Details
|
|
223
|
+
|
|
224
|
+
One
|
|
225
|
+
Two
|
|
226
|
+
Three
|
|
227
|
+
Four
|
|
228
|
+
Five
|
|
229
|
+
Six
|
|
230
|
+
Seven
|
|
231
|
+
Eight
|
|
232
|
+
`);
|
|
233
|
+
wikiFs.writePage("pages/projects/skip/index.md", `---
|
|
234
|
+
title: Skip
|
|
235
|
+
summary: Skip notes
|
|
236
|
+
updated: 2026-05-10
|
|
237
|
+
tags: [Run Book]
|
|
238
|
+
autofix: false
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
# Skip
|
|
242
|
+
|
|
243
|
+
Long enough body.
|
|
244
|
+
|
|
245
|
+
## Details
|
|
246
|
+
|
|
247
|
+
One
|
|
248
|
+
Two
|
|
249
|
+
Three
|
|
250
|
+
Four
|
|
251
|
+
Five
|
|
252
|
+
Six
|
|
253
|
+
Seven
|
|
254
|
+
Eight
|
|
255
|
+
`);
|
|
256
|
+
wikiFs.writePage("pages/people/alice/index.md", `---
|
|
257
|
+
title: Alice
|
|
258
|
+
summary: Alice notes
|
|
259
|
+
updated: 2026-05-10
|
|
260
|
+
tags: [Run Book]
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
# Alice
|
|
264
|
+
|
|
265
|
+
Long enough body.
|
|
266
|
+
|
|
267
|
+
## Details
|
|
268
|
+
|
|
269
|
+
One
|
|
270
|
+
Two
|
|
271
|
+
Three
|
|
272
|
+
Four
|
|
273
|
+
Five
|
|
274
|
+
Six
|
|
275
|
+
Seven
|
|
276
|
+
Eight
|
|
277
|
+
`);
|
|
278
|
+
wikiFs.writePage("pages/_meta/manual.md", `---
|
|
279
|
+
title: Manual
|
|
280
|
+
summary: System page
|
|
281
|
+
updated: 2026-05-10
|
|
282
|
+
tags: [Run Book]
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
# Manual
|
|
286
|
+
`);
|
|
287
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
|
|
288
|
+
- runbook
|
|
289
|
+
`);
|
|
290
|
+
const beforeSkip = wikiFs.readPage("pages/projects/skip/index.md");
|
|
291
|
+
const beforePeople = wikiFs.readPage("pages/people/alice/index.md");
|
|
292
|
+
const beforeMeta = wikiFs.readPage("pages/_meta/manual.md");
|
|
293
|
+
const report = fix.fixWiki({
|
|
294
|
+
dryRun: true,
|
|
295
|
+
fixes: ["tag-normalize"],
|
|
296
|
+
pathGlob: "pages/projects/**",
|
|
297
|
+
});
|
|
298
|
+
assert.equal(report.changedFiles, 1);
|
|
299
|
+
assert.deepEqual(report.files.map((entry) => entry.path), ["pages/projects/match/index.md"]);
|
|
300
|
+
assert.deepEqual(report.files[0]?.changes.map((change) => change.rule), ["tag-normalize"]);
|
|
301
|
+
assert.doesNotMatch(report.diff, /pages\/projects\/skip\/index\.md/);
|
|
302
|
+
assert.doesNotMatch(report.diff, /pages\/people\/alice\/index\.md/);
|
|
303
|
+
assert.doesNotMatch(report.diff, /pages\/_meta\/manual\.md/);
|
|
304
|
+
assert.equal(wikiFs.readPage("pages/projects/skip/index.md"), beforeSkip);
|
|
305
|
+
assert.equal(wikiFs.readPage("pages/people/alice/index.md"), beforePeople);
|
|
306
|
+
assert.equal(wikiFs.readPage("pages/_meta/manual.md"), beforeMeta);
|
|
307
|
+
});
|
|
308
|
+
test("fixWiki flags unmatched tags even when there is nothing to rewrite", async () => {
|
|
309
|
+
const { fix, wikiFs } = await loadModules();
|
|
310
|
+
wikiFs.ensureWikiStructure();
|
|
311
|
+
wikiFs.writePage("pages/projects/mystery/index.md", `---
|
|
312
|
+
title: Mystery
|
|
313
|
+
summary: Mystery notes
|
|
314
|
+
updated: 2026-05-10
|
|
315
|
+
tags: [Mystery]
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
# Mystery
|
|
319
|
+
|
|
320
|
+
Long enough body.
|
|
321
|
+
|
|
322
|
+
## Details
|
|
323
|
+
|
|
324
|
+
One
|
|
325
|
+
Two
|
|
326
|
+
Three
|
|
327
|
+
Four
|
|
328
|
+
Five
|
|
329
|
+
Six
|
|
330
|
+
Seven
|
|
331
|
+
Eight
|
|
332
|
+
`);
|
|
333
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
|
|
334
|
+
- runbook
|
|
335
|
+
`);
|
|
336
|
+
const report = fix.fixWiki({
|
|
337
|
+
dryRun: true,
|
|
338
|
+
fixes: ["tag-normalize"],
|
|
339
|
+
});
|
|
340
|
+
assert.equal(report.changedFiles, 0);
|
|
341
|
+
assert.equal(report.diff, "");
|
|
342
|
+
assert.deepEqual(report.files, [
|
|
343
|
+
{
|
|
344
|
+
path: "pages/projects/mystery/index.md",
|
|
345
|
+
changes: [],
|
|
346
|
+
unknownTags: ["Mystery"],
|
|
347
|
+
},
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
//# sourceMappingURL=fix.test.js.map
|