chapterhouse 0.3.18 → 0.3.19
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/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
|
@@ -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
|
package/dist/wiki/frontmatter.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
|
|
2
2
|
const SUMMARY_MARKDOWN_RE = /(\*\*|__|[_*`~]|^\s*#+\s|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|^\s*>)/m;
|
|
3
3
|
const FRONTMATTER_TEMPLATE = `---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---`;
|
|
4
|
+
const PROJECT_RULES_HARD_DEFAULTS = {
|
|
5
|
+
auto_pr: true,
|
|
6
|
+
require_worktree: false,
|
|
7
|
+
pr_draft_default: false,
|
|
8
|
+
default_branch: "main",
|
|
9
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
10
|
+
test_command: "",
|
|
11
|
+
build_command: "",
|
|
12
|
+
lint_command: "",
|
|
13
|
+
require_clean_worktree: false,
|
|
14
|
+
};
|
|
15
|
+
const KNOWN_WIKI_FRONTMATTER_FIELDS = new Set([
|
|
16
|
+
"title",
|
|
17
|
+
"summary",
|
|
18
|
+
"updated",
|
|
19
|
+
"tags",
|
|
20
|
+
"autostub",
|
|
21
|
+
"confidence",
|
|
22
|
+
"contested",
|
|
23
|
+
"contradictions",
|
|
24
|
+
"related",
|
|
25
|
+
]);
|
|
26
|
+
const PROJECT_RULE_HARD_FIELDS = [
|
|
27
|
+
"auto_pr",
|
|
28
|
+
"require_worktree",
|
|
29
|
+
"pr_draft_default",
|
|
30
|
+
"default_branch",
|
|
31
|
+
"commit_co_author",
|
|
32
|
+
"test_command",
|
|
33
|
+
"build_command",
|
|
34
|
+
"lint_command",
|
|
35
|
+
"require_clean_worktree",
|
|
36
|
+
];
|
|
37
|
+
const KNOWN_PROJECT_RULE_FIELDS = new Set(PROJECT_RULE_HARD_FIELDS);
|
|
4
38
|
export function parseWikiFrontmatter(content) {
|
|
5
39
|
const match = content.match(FRONTMATTER_RE);
|
|
6
40
|
if (!match) {
|
|
@@ -41,6 +75,15 @@ export function parseWikiFrontmatter(content) {
|
|
|
41
75
|
else
|
|
42
76
|
parsed.metadata[key] = value;
|
|
43
77
|
break;
|
|
78
|
+
case "auto_pr":
|
|
79
|
+
case "require_worktree":
|
|
80
|
+
case "pr_draft_default":
|
|
81
|
+
case "require_clean_worktree":
|
|
82
|
+
if (typeof value === "boolean")
|
|
83
|
+
parsed[key] = value;
|
|
84
|
+
else
|
|
85
|
+
parsed.metadata[key] = value;
|
|
86
|
+
break;
|
|
44
87
|
case "confidence":
|
|
45
88
|
if (value === "high" || value === "medium" || value === "low") {
|
|
46
89
|
parsed.confidence = value;
|
|
@@ -49,6 +92,16 @@ export function parseWikiFrontmatter(content) {
|
|
|
49
92
|
parsed.metadata[key] = value;
|
|
50
93
|
}
|
|
51
94
|
break;
|
|
95
|
+
case "default_branch":
|
|
96
|
+
case "commit_co_author":
|
|
97
|
+
case "test_command":
|
|
98
|
+
case "build_command":
|
|
99
|
+
case "lint_command":
|
|
100
|
+
if (typeof value === "string")
|
|
101
|
+
parsed[key] = value;
|
|
102
|
+
else
|
|
103
|
+
parsed.metadata[key] = value;
|
|
104
|
+
break;
|
|
52
105
|
default:
|
|
53
106
|
parsed.metadata[key] = value;
|
|
54
107
|
break;
|
|
@@ -59,6 +112,17 @@ export function parseWikiFrontmatter(content) {
|
|
|
59
112
|
body: content.slice(match[0].length),
|
|
60
113
|
};
|
|
61
114
|
}
|
|
115
|
+
export function parseProjectRulesFrontmatter(content) {
|
|
116
|
+
const { parsed, body } = parseWikiFrontmatter(content);
|
|
117
|
+
return {
|
|
118
|
+
parsed: {
|
|
119
|
+
...parsed,
|
|
120
|
+
hardRules: materializeProjectRulesHardFields(parsed),
|
|
121
|
+
},
|
|
122
|
+
body,
|
|
123
|
+
warnings: collectUnknownProjectRuleWarnings(parsed.metadata),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
62
126
|
export function hasWikiFrontmatter(content) {
|
|
63
127
|
return FRONTMATTER_RE.test(content);
|
|
64
128
|
}
|
|
@@ -125,6 +189,25 @@ export function validateWikiFrontmatter(content, options = {}) {
|
|
|
125
189
|
errors,
|
|
126
190
|
};
|
|
127
191
|
}
|
|
192
|
+
export function validateProjectRulesFrontmatter(content, options = {}) {
|
|
193
|
+
const base = validateWikiFrontmatter(content, options);
|
|
194
|
+
const { parsed, warnings } = parseProjectRulesFrontmatter(content);
|
|
195
|
+
const errors = [...base.errors];
|
|
196
|
+
for (const field of PROJECT_RULE_HARD_FIELDS) {
|
|
197
|
+
if (field in parsed.metadata) {
|
|
198
|
+
errors.push({
|
|
199
|
+
rule: "invalid-field-type",
|
|
200
|
+
field,
|
|
201
|
+
message: formatFrontmatterMessage(`invalid '${field}' type`),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
valid: errors.length === 0,
|
|
207
|
+
errors,
|
|
208
|
+
warnings,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
128
211
|
function formatFrontmatterMessage(reason) {
|
|
129
212
|
return `Wiki page frontmatter violates the required shape: ${reason}. Use:\n${FRONTMATTER_TEMPLATE}`;
|
|
130
213
|
}
|
|
@@ -145,4 +228,26 @@ function parseValue(rawValue) {
|
|
|
145
228
|
function stripQuotes(value) {
|
|
146
229
|
return value.replace(/^['"]|['"]$/g, "");
|
|
147
230
|
}
|
|
231
|
+
function materializeProjectRulesHardFields(parsed) {
|
|
232
|
+
return {
|
|
233
|
+
auto_pr: parsed.auto_pr ?? PROJECT_RULES_HARD_DEFAULTS.auto_pr,
|
|
234
|
+
require_worktree: parsed.require_worktree ?? PROJECT_RULES_HARD_DEFAULTS.require_worktree,
|
|
235
|
+
pr_draft_default: parsed.pr_draft_default ?? PROJECT_RULES_HARD_DEFAULTS.pr_draft_default,
|
|
236
|
+
default_branch: parsed.default_branch ?? PROJECT_RULES_HARD_DEFAULTS.default_branch,
|
|
237
|
+
commit_co_author: parsed.commit_co_author ?? PROJECT_RULES_HARD_DEFAULTS.commit_co_author,
|
|
238
|
+
test_command: parsed.test_command ?? PROJECT_RULES_HARD_DEFAULTS.test_command,
|
|
239
|
+
build_command: parsed.build_command ?? PROJECT_RULES_HARD_DEFAULTS.build_command,
|
|
240
|
+
lint_command: parsed.lint_command ?? PROJECT_RULES_HARD_DEFAULTS.lint_command,
|
|
241
|
+
require_clean_worktree: parsed.require_clean_worktree ?? PROJECT_RULES_HARD_DEFAULTS.require_clean_worktree,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function collectUnknownProjectRuleWarnings(metadata) {
|
|
245
|
+
return Object.keys(metadata)
|
|
246
|
+
.filter((field) => !KNOWN_WIKI_FRONTMATTER_FIELDS.has(field) && !KNOWN_PROJECT_RULE_FIELDS.has(field))
|
|
247
|
+
.map((field) => ({
|
|
248
|
+
rule: "unknown-project-rule-key",
|
|
249
|
+
field,
|
|
250
|
+
message: `Project rules frontmatter includes unknown key '${field}'.`,
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
148
253
|
//# sourceMappingURL=frontmatter.js.map
|
|
@@ -106,4 +106,124 @@ tags: [engineering, made-up-tag]
|
|
|
106
106
|
assert.deepEqual(result.errors.map((error) => error.rule), ["unknown-tag"]);
|
|
107
107
|
assert.match(result.errors[0]?.message ?? "", /Add it to `pages\/_meta\/taxonomy\.md` first\./);
|
|
108
108
|
});
|
|
109
|
+
test("parseProjectRulesFrontmatter parses typed hard-rule fields and flags unknown keys", async () => {
|
|
110
|
+
const { parseProjectRulesFrontmatter } = await loadFrontmatterModule();
|
|
111
|
+
const result = parseProjectRulesFrontmatter(`---
|
|
112
|
+
title: Project rules for chapterhouse
|
|
113
|
+
summary: Project-specific operating rules for Chapterhouse itself.
|
|
114
|
+
updated: 2026-05-12
|
|
115
|
+
tags: [engineering, workflow]
|
|
116
|
+
related: []
|
|
117
|
+
auto_pr: false
|
|
118
|
+
require_worktree: true
|
|
119
|
+
pr_draft_default: true
|
|
120
|
+
default_branch: trunk
|
|
121
|
+
commit_co_author: Jane Doe <jane@example.com>
|
|
122
|
+
test_command: npm test
|
|
123
|
+
build_command: npm run build
|
|
124
|
+
lint_command: npm run lint
|
|
125
|
+
require_clean_worktree: true
|
|
126
|
+
custom_rule: preserve-me
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Soft Rules
|
|
130
|
+
`);
|
|
131
|
+
assert.deepEqual(result, {
|
|
132
|
+
parsed: {
|
|
133
|
+
title: "Project rules for chapterhouse",
|
|
134
|
+
summary: "Project-specific operating rules for Chapterhouse itself.",
|
|
135
|
+
updated: "2026-05-12",
|
|
136
|
+
tags: ["engineering", "workflow"],
|
|
137
|
+
related: [],
|
|
138
|
+
auto_pr: false,
|
|
139
|
+
require_worktree: true,
|
|
140
|
+
pr_draft_default: true,
|
|
141
|
+
default_branch: "trunk",
|
|
142
|
+
commit_co_author: "Jane Doe <jane@example.com>",
|
|
143
|
+
test_command: "npm test",
|
|
144
|
+
build_command: "npm run build",
|
|
145
|
+
lint_command: "npm run lint",
|
|
146
|
+
require_clean_worktree: true,
|
|
147
|
+
metadata: {
|
|
148
|
+
custom_rule: "preserve-me",
|
|
149
|
+
},
|
|
150
|
+
hardRules: {
|
|
151
|
+
auto_pr: false,
|
|
152
|
+
require_worktree: true,
|
|
153
|
+
pr_draft_default: true,
|
|
154
|
+
default_branch: "trunk",
|
|
155
|
+
commit_co_author: "Jane Doe <jane@example.com>",
|
|
156
|
+
test_command: "npm test",
|
|
157
|
+
build_command: "npm run build",
|
|
158
|
+
lint_command: "npm run lint",
|
|
159
|
+
require_clean_worktree: true,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
body: "## Soft Rules\n",
|
|
163
|
+
warnings: [
|
|
164
|
+
{
|
|
165
|
+
rule: "unknown-project-rule-key",
|
|
166
|
+
field: "custom_rule",
|
|
167
|
+
message: "Project rules frontmatter includes unknown key 'custom_rule'.",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
test("parseProjectRulesFrontmatter materializes defaults for omitted hard-rule fields", async () => {
|
|
173
|
+
const { parseProjectRulesFrontmatter } = await loadFrontmatterModule();
|
|
174
|
+
const result = parseProjectRulesFrontmatter(`---
|
|
175
|
+
title: Project rules for chapterhouse
|
|
176
|
+
summary: Project-specific operating rules for Chapterhouse itself.
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Soft Rules
|
|
180
|
+
`);
|
|
181
|
+
assert.deepEqual(result.parsed.hardRules, {
|
|
182
|
+
auto_pr: true,
|
|
183
|
+
require_worktree: false,
|
|
184
|
+
pr_draft_default: false,
|
|
185
|
+
default_branch: "main",
|
|
186
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
187
|
+
test_command: "",
|
|
188
|
+
build_command: "",
|
|
189
|
+
lint_command: "",
|
|
190
|
+
require_clean_worktree: false,
|
|
191
|
+
});
|
|
192
|
+
assert.deepEqual(result.warnings, []);
|
|
193
|
+
});
|
|
194
|
+
test("validateProjectRulesFrontmatter rejects invalid hard-rule field types and warns on unknown keys", async () => {
|
|
195
|
+
const { validateProjectRulesFrontmatter } = await loadFrontmatterModule();
|
|
196
|
+
const result = validateProjectRulesFrontmatter(`---
|
|
197
|
+
title: Project rules for chapterhouse
|
|
198
|
+
summary: Project-specific operating rules for Chapterhouse itself.
|
|
199
|
+
auto_pr: nope
|
|
200
|
+
default_branch: [main]
|
|
201
|
+
test_command: [npm test]
|
|
202
|
+
require_clean_worktree: "yes"
|
|
203
|
+
custom_rule: preserve-me
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Soft Rules
|
|
207
|
+
`);
|
|
208
|
+
assert.equal(result.valid, false);
|
|
209
|
+
assert.deepEqual(result.errors.map((error) => error.rule), [
|
|
210
|
+
"invalid-field-type",
|
|
211
|
+
"invalid-field-type",
|
|
212
|
+
"invalid-field-type",
|
|
213
|
+
"invalid-field-type",
|
|
214
|
+
]);
|
|
215
|
+
assert.deepEqual(result.errors.map((error) => error.field), [
|
|
216
|
+
"auto_pr",
|
|
217
|
+
"default_branch",
|
|
218
|
+
"test_command",
|
|
219
|
+
"require_clean_worktree",
|
|
220
|
+
]);
|
|
221
|
+
assert.deepEqual(result.warnings, [
|
|
222
|
+
{
|
|
223
|
+
rule: "unknown-project-rule-key",
|
|
224
|
+
field: "custom_rule",
|
|
225
|
+
message: "Project rules frontmatter includes unknown key 'custom_rule'.",
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
109
229
|
//# sourceMappingURL=frontmatter.test.js.map
|
package/package.json
CHANGED