@panaversity/ksor 0.0.1 → 0.0.2
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/CHANGELOG.md +23 -0
- package/README.md +24 -6
- package/dist/cli.mjs +344 -2
- package/docs/index.md +34 -6
- package/package.json +3 -1
- package/templates/LICENSE +23 -0
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +43 -0
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +39 -0
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +782 -0
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +46 -0
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +43 -0
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +39 -0
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +782 -0
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +46 -0
- package/templates/scaffold/.gemini/settings.json +5 -0
- package/templates/scaffold/.gitattributes +5 -0
- package/templates/scaffold/.github/workflows/validate.yml +23 -0
- package/templates/scaffold/AGENTS.md +104 -0
- package/templates/scaffold/CLAUDE.md +1 -0
- package/templates/scaffold/README.md +63 -0
- package/templates/scaffold/gitignore +13 -0
- package/templates/scaffold/instance.md +26 -0
- package/templates/scaffold/knowledge/example.md +23 -0
- package/templates/scaffold/package.json +15 -0
- package/templates/scaffold/pnpm-lock.yaml +4041 -0
- package/templates/scaffold/pnpm-workspace.yaml +19 -0
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +83 -0
- package/templates/scaffold/system/site/app/api/search/route.ts +11 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +53 -0
- package/templates/scaffold/system/site/app/docs/layout.tsx +24 -0
- package/templates/scaffold/system/site/app/global.css +26 -0
- package/templates/scaffold/system/site/app/icon.png +0 -0
- package/templates/scaffold/system/site/app/layout.tsx +41 -0
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +10 -0
- package/templates/scaffold/system/site/app/llms.txt/route.ts +15 -0
- package/templates/scaffold/system/site/components/built-with.tsx +18 -0
- package/templates/scaffold/system/site/components/mdx.tsx +15 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +17 -0
- package/templates/scaffold/system/site/lib/shared.ts +51 -0
- package/templates/scaffold/system/site/lib/source.ts +119 -0
- package/templates/scaffold/system/site/next-env.d.ts +6 -0
- package/templates/scaffold/system/site/next.config.mjs +32 -0
- package/templates/scaffold/system/site/package.json +29 -0
- package/templates/scaffold/system/site/postcss.config.mjs +7 -0
- package/templates/scaffold/system/site/source.config.ts +35 -0
- package/templates/scaffold/system/site/tsconfig.json +35 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The record's format rules as a program — dependency-free Node, owned by
|
|
3
|
+
// this repository. Every failure states what is wrong, why the rule exists,
|
|
4
|
+
// and how to fix it, so anyone (human or agent) self-corrects without a
|
|
5
|
+
// reviewer. Run as `pnpm check` or directly: node .agents/skills/format-checker/check.mjs
|
|
6
|
+
|
|
7
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
12
|
+
const knowledgeDir = path.join(root, "knowledge");
|
|
13
|
+
const problems = [];
|
|
14
|
+
|
|
15
|
+
function problem(where, what, why, fix) {
|
|
16
|
+
problems.push(`${where}\n problem: ${what}\n why: ${why}\n fix: ${fix}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// knowledge/: CommonMark .md + assets only, governed frontmatter, safe names
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const ALLOWED_KEYS = new Set([
|
|
23
|
+
"title",
|
|
24
|
+
"description",
|
|
25
|
+
"status",
|
|
26
|
+
"owner",
|
|
27
|
+
"provenance",
|
|
28
|
+
"effective",
|
|
29
|
+
"superseded",
|
|
30
|
+
"superseded_by",
|
|
31
|
+
"order",
|
|
32
|
+
]);
|
|
33
|
+
const REQUIRED_KEYS = ["title", "status"]; // level 0 — the ladder, not a gate
|
|
34
|
+
const STATUS_VALUES = new Set(["draft", "review", "approved", "superseded"]);
|
|
35
|
+
const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
|
|
36
|
+
|
|
37
|
+
// PNG integrity, dependency-free: signature + per-chunk CRC-32. A damaged
|
|
38
|
+
// image beside a document is a check-time problem with the file named, never
|
|
39
|
+
// a build-time 500 with no filename in it.
|
|
40
|
+
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
|
41
|
+
let c = n;
|
|
42
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
43
|
+
return c >>> 0;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function crc32(bytes, start, end) {
|
|
47
|
+
let c = 0xffffffff;
|
|
48
|
+
for (let i = start; i < end; i += 1) {
|
|
49
|
+
c = (CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The first defect in a PNG file, or null when every chunk checks out. */
|
|
55
|
+
function firstBrokenPngChunk(file) {
|
|
56
|
+
const bytes = readFileSync(file);
|
|
57
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
58
|
+
if (bytes.length < 8 || signature.some((b, i) => bytes[i] !== b)) {
|
|
59
|
+
return "bad signature";
|
|
60
|
+
}
|
|
61
|
+
let offset = 8;
|
|
62
|
+
while (offset + 12 <= bytes.length) {
|
|
63
|
+
const length = bytes.readUInt32BE(offset);
|
|
64
|
+
const name = bytes.toString("latin1", offset + 4, offset + 8);
|
|
65
|
+
const dataEnd = offset + 8 + length;
|
|
66
|
+
if (dataEnd + 4 > bytes.length) return `truncated ${name} chunk`;
|
|
67
|
+
const stored = bytes.readUInt32BE(dataEnd);
|
|
68
|
+
if (crc32(bytes, offset + 4, dataEnd) !== stored) return `CRC error in ${name} chunk`;
|
|
69
|
+
if (name === "IEND") return null;
|
|
70
|
+
offset = dataEnd + 4;
|
|
71
|
+
}
|
|
72
|
+
return "missing IEND chunk";
|
|
73
|
+
}
|
|
74
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
75
|
+
// Files the operating system writes behind the author's back. Ignored, never
|
|
76
|
+
// reported: the .gitignore already keeps them out of git.
|
|
77
|
+
const OS_JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
78
|
+
|
|
79
|
+
function walkFiles(dir) {
|
|
80
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
81
|
+
const p = path.join(dir, entry.name);
|
|
82
|
+
return entry.isDirectory() ? walkFiles(p) : [p];
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function walkDirs(dir) {
|
|
87
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
88
|
+
const p = path.join(dir, entry.name);
|
|
89
|
+
return entry.isDirectory() ? [p, ...walkDirs(p)] : [];
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unquote(value) {
|
|
94
|
+
return value.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The frontmatter block, two levels deep (`ksor:` has children; `provenance:`
|
|
99
|
+
* has list items). Returns null when there is no block at all, and collects
|
|
100
|
+
* every line that is neither `key: value`, a list item, an indented
|
|
101
|
+
* continuation, nor blank — those mean the block was never closed.
|
|
102
|
+
*/
|
|
103
|
+
function parseFrontmatter(text) {
|
|
104
|
+
// An editor's byte-order mark is invisible to the author; it must not be
|
|
105
|
+
// the reason a document reads as ungoverned.
|
|
106
|
+
const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
|
|
107
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(normalized);
|
|
108
|
+
if (!match) return null;
|
|
109
|
+
const keys = new Map();
|
|
110
|
+
const children = new Map();
|
|
111
|
+
const quoted = new Set();
|
|
112
|
+
const malformedQuote = new Map();
|
|
113
|
+
const malformed = [];
|
|
114
|
+
const duplicates = [];
|
|
115
|
+
const tightColons = [];
|
|
116
|
+
const tabIndents = [];
|
|
117
|
+
let current = null;
|
|
118
|
+
for (const raw of match[1].split("\n")) {
|
|
119
|
+
const line = raw.replace(/[ \t]+$/, "");
|
|
120
|
+
if (line === "") continue;
|
|
121
|
+
// YAML requires a space after the colon and refuses tab indentation —
|
|
122
|
+
// both parsed here fine and failed the build (review findings, 2026-08-18).
|
|
123
|
+
if (/^[A-Za-z_][\w-]*:\S/.test(line)) tightColons.push(line);
|
|
124
|
+
if (line.startsWith(" ")) tabIndents.push(line);
|
|
125
|
+
const top = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
126
|
+
if (top) {
|
|
127
|
+
current = top[1];
|
|
128
|
+
// A Map silently keeps the last write; YAML refuses the document
|
|
129
|
+
// (review finding, 2026-08-18: green check, red build).
|
|
130
|
+
if (keys.has(current)) duplicates.push(current);
|
|
131
|
+
const rawValue = top[2].trim();
|
|
132
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(rawValue) || /^'(?:[^']|'')*'$/.test(rawValue)) {
|
|
133
|
+
quoted.add(current);
|
|
134
|
+
} else if (/^["']/.test(rawValue)) {
|
|
135
|
+
// Starts like a quote but is not one clean quoted string — YAML
|
|
136
|
+
// refuses it, and unquote() below hides the evidence (review
|
|
137
|
+
// finding, 2026-08-18: `"a" and "b"` slipped every danger test).
|
|
138
|
+
malformedQuote.set(current, rawValue);
|
|
139
|
+
}
|
|
140
|
+
keys.set(current, unquote(top[2]));
|
|
141
|
+
children.set(current, new Map());
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const nested = /^[ \t]+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
145
|
+
if (nested && current !== null) {
|
|
146
|
+
children.get(current).set(nested[1], unquote(nested[2]));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (/^[ \t]*-([ \t]|$)/.test(line) || /^[ \t]+\S/.test(line)) continue;
|
|
150
|
+
malformed.push(line.trim());
|
|
151
|
+
}
|
|
152
|
+
return { keys, children, quoted, malformedQuote, duplicates, malformed, tightColons, tabIndents };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Code is prose about links, never links. Strips fenced blocks (``` and ~~~,
|
|
157
|
+
* closed by a run of the same character at least as long as the opener) and
|
|
158
|
+
* inline code spans of any backtick-run length.
|
|
159
|
+
*/
|
|
160
|
+
function stripCode(text) {
|
|
161
|
+
const kept = [];
|
|
162
|
+
let fence = null;
|
|
163
|
+
let blank = true;
|
|
164
|
+
let indented = false;
|
|
165
|
+
for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
|
|
166
|
+
if (fence) {
|
|
167
|
+
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
168
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
172
|
+
if (open) {
|
|
173
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Indented code blocks: a 4-space/tab-indented run opened after a blank
|
|
177
|
+
// line is treated as code (review finding 2026-08-18 — links inside
|
|
178
|
+
// code samples were checked as real). An indented line that STARTS a
|
|
179
|
+
// list item stays content: nested lists sit at exactly this indent and
|
|
180
|
+
// carry real links (second review finding, same day) — code that
|
|
181
|
+
// happens to open with a markdown bullet is the rarer beast.
|
|
182
|
+
if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
|
|
183
|
+
if (blank || indented) {
|
|
184
|
+
indented = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
} else if (line.trim() !== "") {
|
|
188
|
+
indented = false;
|
|
189
|
+
}
|
|
190
|
+
blank = line.trim() === "";
|
|
191
|
+
kept.push(line);
|
|
192
|
+
}
|
|
193
|
+
// Spans stripped per PARAGRAPH: CommonMark code spans may cross lines, so
|
|
194
|
+
// a line bound flagged links inside real multi-line spans — while a
|
|
195
|
+
// document-wide strip let one stray backtick pair with another pages
|
|
196
|
+
// later and silently exempt every link between them. A paragraph bounds
|
|
197
|
+
// both failure modes (review findings, 2026-08-18, both rounds).
|
|
198
|
+
return kept
|
|
199
|
+
.join("\n")
|
|
200
|
+
.split(/\n{2,}/)
|
|
201
|
+
.map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
|
|
202
|
+
.join("\n\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Every shape CommonMark gives a link destination: inline (bare or
|
|
206
|
+
// <angle-bracketed>, with a "double", 'single' or (paren) title) and the
|
|
207
|
+
// reference definitions that inline `[text][label]` links point at.
|
|
208
|
+
const INLINE_LINK =
|
|
209
|
+
/\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
|
210
|
+
const REFERENCE_DEFINITION =
|
|
211
|
+
/^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
|
|
212
|
+
|
|
213
|
+
function linkTargets(body) {
|
|
214
|
+
const raw = [];
|
|
215
|
+
for (const match of body.matchAll(INLINE_LINK)) raw.push(match[1]);
|
|
216
|
+
for (const match of body.matchAll(REFERENCE_DEFINITION)) raw.push(match[1]);
|
|
217
|
+
// <…> exists so a destination may contain spaces; the brackets are syntax.
|
|
218
|
+
return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function checkLinkTarget(rel, docPath, target) {
|
|
222
|
+
// Anything with a URI scheme (https:, mailto:, tel:, ftp:, …) or a
|
|
223
|
+
// protocol-relative // host leaves the record on purpose — only relative
|
|
224
|
+
// paths are the record's own links (review finding 2026-08-18: tel: was
|
|
225
|
+
// reported as a dead file and //host as an escape).
|
|
226
|
+
if (target === "" || target.startsWith("#") || target.startsWith("//")) return;
|
|
227
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return;
|
|
228
|
+
const resolved = path.resolve(path.dirname(docPath), target.split("#")[0]);
|
|
229
|
+
if (!resolved.startsWith(knowledgeDir + path.sep) && resolved !== knowledgeDir) {
|
|
230
|
+
problem(
|
|
231
|
+
rel,
|
|
232
|
+
`link escapes the record: ${target}`,
|
|
233
|
+
"the record must survive without the system — outward links break the walk-away promise",
|
|
234
|
+
"move the asset into knowledge/ beside the document, or use an absolute URL",
|
|
235
|
+
);
|
|
236
|
+
} else if (!existsSync(resolved)) {
|
|
237
|
+
problem(
|
|
238
|
+
rel,
|
|
239
|
+
`dead link: ${target}`,
|
|
240
|
+
"a record with dead internal links serves different truths by path",
|
|
241
|
+
"fix the path or remove the link",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!existsSync(knowledgeDir)) {
|
|
247
|
+
problem(
|
|
248
|
+
"knowledge/",
|
|
249
|
+
"the record directory is missing",
|
|
250
|
+
"a Knowledge System of Record without knowledge/ is not one",
|
|
251
|
+
"restore knowledge/ from git history",
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
const allEntries = walkFiles(knowledgeDir).filter((p) => !OS_JUNK.has(path.basename(p)));
|
|
255
|
+
const files = [];
|
|
256
|
+
for (const p of allEntries) {
|
|
257
|
+
// The record is plain files: a symlink breaks the walk-away copy, and a
|
|
258
|
+
// dangling one crashed the checker with a raw ENOENT before any other
|
|
259
|
+
// problem was reported (review finding, 2026-08-18).
|
|
260
|
+
if (lstatSync(p).isSymbolicLink()) {
|
|
261
|
+
problem(
|
|
262
|
+
path.relative(root, p),
|
|
263
|
+
"symlink in the record",
|
|
264
|
+
"the record must survive being copied anywhere — a symlink carries a machine-local path, and a dangling one is unreadable",
|
|
265
|
+
"replace the link with the file it points at",
|
|
266
|
+
);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
files.push(p);
|
|
270
|
+
}
|
|
271
|
+
const dirs = walkDirs(knowledgeDir);
|
|
272
|
+
const all = [...files, ...dirs];
|
|
273
|
+
const mdFiles = files.filter((p) => p.endsWith(".md"));
|
|
274
|
+
|
|
275
|
+
if (mdFiles.length === 0) {
|
|
276
|
+
problem(
|
|
277
|
+
"knowledge/",
|
|
278
|
+
"the record has no documents",
|
|
279
|
+
"a KSoR is never empty — the site has nothing to render and the record stands behind nothing",
|
|
280
|
+
"restore a document from git history, or add one: knowledge/<name>.md with title + status frontmatter",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// names: windows-safe, lowercase-stable, no spaces, no framework files
|
|
285
|
+
const seenLower = new Map();
|
|
286
|
+
for (const p of all) {
|
|
287
|
+
const rel = path.relative(root, p);
|
|
288
|
+
const base = path.basename(p);
|
|
289
|
+
if (base === "meta.json" || base.endsWith(".mdx")) {
|
|
290
|
+
problem(
|
|
291
|
+
rel,
|
|
292
|
+
base.endsWith(".mdx") ? "MDX file in the record" : "framework file in the record",
|
|
293
|
+
"knowledge/ is CommonMark markdown only — framework grammar breaks the walk-away promise",
|
|
294
|
+
base.endsWith(".mdx")
|
|
295
|
+
? "convert to .md; components belong to the site, not the record"
|
|
296
|
+
: "delete it — sidebar order is the `order` frontmatter key",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const unportable = /[<>:"|?*]/.test(base) || /[. ]$/.test(base) || WINDOWS_RESERVED.test(base);
|
|
300
|
+
const spaced = /\s/.test(base);
|
|
301
|
+
if (unportable || spaced) {
|
|
302
|
+
problem(
|
|
303
|
+
rel,
|
|
304
|
+
spaced && !unportable
|
|
305
|
+
? `"${base}" contains whitespace`
|
|
306
|
+
: `"${base}" is not a portable name`,
|
|
307
|
+
"the path is the document's identity and its URL on every platform — spaces have to be escaped in every link, and Windows rejects these characters outright",
|
|
308
|
+
"use lowercase letters, digits and hyphens; no spaces; no trailing dots; avoid reserved device names",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (/[A-Z]/.test(base)) {
|
|
312
|
+
problem(
|
|
313
|
+
rel,
|
|
314
|
+
"uppercase in filename",
|
|
315
|
+
"paths are identities; case-only differences collide on case-insensitive filesystems",
|
|
316
|
+
"rename to lowercase",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
// eslint-disable-next-line no-control-regex -- the point is the range
|
|
320
|
+
if (/[^\x20-\x7E]/.test(base)) {
|
|
321
|
+
problem(
|
|
322
|
+
rel,
|
|
323
|
+
`"${base}" contains non-ASCII characters`,
|
|
324
|
+
"the path is the document's URL, and site frameworks disagree on how to encode non-ASCII routes — the same document gets a different address on each surface (found live: política.md exported two incompatible routes)",
|
|
325
|
+
"use ascii lowercase letters, digits and hyphens; the title: key carries the document's real name in any language",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const lower = path.relative(root, p).toLowerCase();
|
|
329
|
+
if (seenLower.has(lower) && seenLower.get(lower) !== rel) {
|
|
330
|
+
problem(
|
|
331
|
+
rel,
|
|
332
|
+
`collides with ${seenLower.get(lower)} on case-insensitive filesystems`,
|
|
333
|
+
"two documents that are one file on macOS/Windows cannot both be the record",
|
|
334
|
+
"rename one of them",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
seenLower.set(lower, rel);
|
|
338
|
+
if (files.includes(p) && !p.endsWith(".md") && !ASSET_EXTENSIONS.has(path.extname(p))) {
|
|
339
|
+
problem(
|
|
340
|
+
rel,
|
|
341
|
+
`unexpected file type "${path.extname(p) || base}"`,
|
|
342
|
+
"the record holds markdown and images; other formats cannot be governed or rendered",
|
|
343
|
+
"convert it to markdown (the add-sources skill does this) or move it out of knowledge/",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (files.includes(p) && path.extname(p) === ".png") {
|
|
347
|
+
const brokenChunk = firstBrokenPngChunk(p);
|
|
348
|
+
if (brokenChunk !== null) {
|
|
349
|
+
problem(
|
|
350
|
+
rel,
|
|
351
|
+
`corrupt PNG (${brokenChunk})`,
|
|
352
|
+
"a corrupt image can take the whole site down at build time with an error that never names this file (found live: one bad CRC 500'd every page)",
|
|
353
|
+
"re-export or re-download the image; the bytes on disk are damaged",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (base.startsWith("_")) {
|
|
358
|
+
// found live 2026-08-18: one shell's framework treats _files as hidden
|
|
359
|
+
// partials and skips them, the other publishes them — the same record,
|
|
360
|
+
// one document present on one surface and dead-linked on the other.
|
|
361
|
+
problem(
|
|
362
|
+
rel,
|
|
363
|
+
"underscore-prefixed name",
|
|
364
|
+
"site frameworks treat _files as hidden partials — the record has no hidden documents; every document is published or it is not in the record",
|
|
365
|
+
"rename without the leading underscore",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (/\(.*\)/.test(base)) {
|
|
369
|
+
problem(
|
|
370
|
+
rel,
|
|
371
|
+
"parenthesized name",
|
|
372
|
+
"renderers strip parenthesized segments from routes, giving one document two identities",
|
|
373
|
+
"rename without parentheses",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// foo.md vs foo/index.md route collisions
|
|
379
|
+
for (const p of mdFiles) {
|
|
380
|
+
const sibling = p.replace(/\.md$/, "");
|
|
381
|
+
if (existsSync(path.join(sibling, "index.md"))) {
|
|
382
|
+
problem(
|
|
383
|
+
path.relative(root, p),
|
|
384
|
+
`route collision with ${path.relative(root, path.join(sibling, "index.md"))}`,
|
|
385
|
+
"both map to the same URL — one identity, two documents",
|
|
386
|
+
"keep one; merge the content",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// frontmatter + links per document
|
|
392
|
+
for (const p of mdFiles) {
|
|
393
|
+
const rel = path.relative(root, p);
|
|
394
|
+
const text = readFileSync(p, "utf8");
|
|
395
|
+
const fm = parseFrontmatter(text);
|
|
396
|
+
if (fm === null) {
|
|
397
|
+
problem(
|
|
398
|
+
rel,
|
|
399
|
+
"no frontmatter",
|
|
400
|
+
"a document without identity and lifecycle is ungoverned",
|
|
401
|
+
"start the file with ---\\ntitle: ...\\nstatus: draft\\n---",
|
|
402
|
+
);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (fm.malformed.length > 0) {
|
|
406
|
+
problem(
|
|
407
|
+
rel,
|
|
408
|
+
`unclosed or malformed frontmatter — this is not a frontmatter line: "${fm.malformed[0]}"`,
|
|
409
|
+
"an unclosed block swallows the body: the checker reads prose as governance, and the site renders a document with no title",
|
|
410
|
+
"close the block with --- on its own line; every line inside it is `key: value` or a `- list item` — no prose, no comments",
|
|
411
|
+
);
|
|
412
|
+
} else {
|
|
413
|
+
for (const line of fm.tightColons) {
|
|
414
|
+
problem(
|
|
415
|
+
rel,
|
|
416
|
+
`missing space after the colon: ${line}`,
|
|
417
|
+
"YAML needs `key: value` — without the space the build fails after this check passed",
|
|
418
|
+
"add a space after the colon",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
for (const line of fm.tabIndents) {
|
|
422
|
+
problem(
|
|
423
|
+
rel,
|
|
424
|
+
`tab-indented frontmatter: ${JSON.stringify(line)}`,
|
|
425
|
+
"YAML refuses tabs as indentation — the build fails after this check passed",
|
|
426
|
+
"indent with spaces",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
for (const dup of fm.duplicates) {
|
|
430
|
+
problem(
|
|
431
|
+
rel,
|
|
432
|
+
`duplicate frontmatter key: ${dup}`,
|
|
433
|
+
"YAML refuses a repeated key, so the build would fail after this check passed — and only one of the two values can be the truth",
|
|
434
|
+
`keep one ${dup}: line`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
for (const key of REQUIRED_KEYS) {
|
|
438
|
+
if (!fm.keys.has(key) || fm.keys.get(key) === "") {
|
|
439
|
+
problem(
|
|
440
|
+
rel,
|
|
441
|
+
`missing frontmatter key: ${key}`,
|
|
442
|
+
"title names the document; status places it in the governance lifecycle",
|
|
443
|
+
`add ${key}: to the frontmatter`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const key of fm.keys.keys()) {
|
|
448
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
449
|
+
problem(
|
|
450
|
+
rel,
|
|
451
|
+
`unknown frontmatter key: ${key}`,
|
|
452
|
+
key === "id" || key === "name"
|
|
453
|
+
? "identity derives from the file path — an authored id gives one document two identities"
|
|
454
|
+
: "the frontmatter key set is closed so every key means one thing everywhere",
|
|
455
|
+
`remove "${key}:" (allowed: ${[...ALLOWED_KEYS].join(", ")})`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const [key, value] of fm.keys) {
|
|
460
|
+
// The site parses this block with a real YAML parser; values it
|
|
461
|
+
// rejects must be refused HERE with a remedy, not later as a raw
|
|
462
|
+
// YAMLException from inside node_modules (found live 2026-08-18:
|
|
463
|
+
// an unquoted colon in a title killed both site builds after a
|
|
464
|
+
// green check).
|
|
465
|
+
if (fm.malformedQuote.has(key)) {
|
|
466
|
+
problem(
|
|
467
|
+
rel,
|
|
468
|
+
`frontmatter quoting is malformed: ${key}: ${fm.malformedQuote.get(key)}`,
|
|
469
|
+
"the value starts like a quoted string but is not one clean quoted string — YAML refuses it, so the build would fail after this check passed",
|
|
470
|
+
`quote the whole value exactly once: ${key}: "..."`,
|
|
471
|
+
);
|
|
472
|
+
} else if (
|
|
473
|
+
!fm.quoted.has(key) &&
|
|
474
|
+
(value.includes(": ") ||
|
|
475
|
+
value.endsWith(":") ||
|
|
476
|
+
value.includes(" #") ||
|
|
477
|
+
// A complete [flow, list] is valid YAML; only a value that STARTS
|
|
478
|
+
// like one without finishing it is broken (review finding,
|
|
479
|
+
// 2026-08-18: a valid flow provenance was refused with a remedy
|
|
480
|
+
// that was itself malformed).
|
|
481
|
+
(value.startsWith("[") && !/^\[.*\]$/.test(value)) ||
|
|
482
|
+
/^[{>|&*!%@`'"]/.test(value) ||
|
|
483
|
+
/^-(\s|$)/.test(value))
|
|
484
|
+
) {
|
|
485
|
+
problem(
|
|
486
|
+
rel,
|
|
487
|
+
`frontmatter value needs quoting: ${key}: ${value}`,
|
|
488
|
+
"the site reads this block as YAML: unquoted colons and leading [ { > | & * ! % @ ` fail the build after this check passed, and ` #` starts a YAML comment — the page would carry a silently truncated value",
|
|
489
|
+
`quote it: ${key}: "${value}"`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// provenance is a LIST — the site's schema enforces it at build, so a
|
|
494
|
+
// scalar value passing here failed there with a schema error naming
|
|
495
|
+
// neither file nor rule (review finding, 2026-08-18).
|
|
496
|
+
for (const [key, value] of fm.keys) {
|
|
497
|
+
if (key !== "provenance" && !fm.quoted.has(key) && /^\[.*\]$/.test(value)) {
|
|
498
|
+
problem(
|
|
499
|
+
rel,
|
|
500
|
+
`${key} is one value, not a list: ${value}`,
|
|
501
|
+
"YAML reads [..] as a list, and the site's schema wants a single value here — the build would fail after this check passed",
|
|
502
|
+
`write it plain (or quoted): ${key}: "${value}"`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const provenance = fm.keys.get("provenance");
|
|
507
|
+
if (
|
|
508
|
+
provenance !== undefined &&
|
|
509
|
+
provenance !== "" &&
|
|
510
|
+
(fm.quoted.has("provenance") || !/^\[.*\]$/.test(provenance))
|
|
511
|
+
) {
|
|
512
|
+
problem(
|
|
513
|
+
rel,
|
|
514
|
+
`provenance is a list, not a value: ${provenance}`,
|
|
515
|
+
"each source is one entry so citations can point at exactly one of them",
|
|
516
|
+
`write it as list items:\n provenance:\n - ${provenance}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const status = fm.keys.get("status");
|
|
520
|
+
if (status !== undefined && status !== "" && !STATUS_VALUES.has(status)) {
|
|
521
|
+
problem(
|
|
522
|
+
rel,
|
|
523
|
+
`status "${status}" is not one of ${[...STATUS_VALUES].join(" | ")}`,
|
|
524
|
+
"the lifecycle is a closed set; free-form states cannot be gated on",
|
|
525
|
+
"pick the closest lifecycle state",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const successor = fm.keys.get("superseded_by");
|
|
529
|
+
if (status === "superseded" && !successor) {
|
|
530
|
+
problem(
|
|
531
|
+
rel,
|
|
532
|
+
"superseded without superseded_by",
|
|
533
|
+
"a replaced document must point at its successor or readers dead-end on stale truth",
|
|
534
|
+
"add superseded_by: ./<successor>.md",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
// A successor that names a path must be a document that exists: the
|
|
538
|
+
// pointer is the whole value of marking something superseded.
|
|
539
|
+
if (successor && (/^\.{1,2}\//.test(successor) || successor.toLowerCase().endsWith(".md"))) {
|
|
540
|
+
const resolved = path.resolve(path.dirname(p), successor.split("#")[0]);
|
|
541
|
+
if (!resolved.startsWith(knowledgeDir + path.sep)) {
|
|
542
|
+
problem(
|
|
543
|
+
rel,
|
|
544
|
+
`superseded_by leaves the record: ${successor}`,
|
|
545
|
+
"the successor is what readers are sent to instead — outside knowledge/ it is not a governed document",
|
|
546
|
+
"point superseded_by at a document inside knowledge/",
|
|
547
|
+
);
|
|
548
|
+
} else if (!existsSync(resolved)) {
|
|
549
|
+
problem(
|
|
550
|
+
rel,
|
|
551
|
+
`superseded_by points at a document that does not exist: ${successor}`,
|
|
552
|
+
"a replaced document must hand the reader its successor — a broken pointer dead-ends them on stale truth",
|
|
553
|
+
"fix the path (it resolves relative to this document), or write the successor first",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// links: resolve, and never escape the record
|
|
559
|
+
for (const target of linkTargets(stripCode(text))) checkLinkTarget(rel, p, target);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// instance.md: the identity of this SoR — format 1, closed key set
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
const INSTANCE_KEYS = new Set(["format", "name", "ksor", "site"]);
|
|
567
|
+
const INSTANCE_KSOR_KEYS = new Set(["requires", "scaffolded"]);
|
|
568
|
+
const INSTANCE_SITE_KEYS = new Set(["url"]);
|
|
569
|
+
|
|
570
|
+
const instanceMd = path.join(root, "instance.md");
|
|
571
|
+
if (!existsSync(instanceMd)) {
|
|
572
|
+
problem(
|
|
573
|
+
"instance.md",
|
|
574
|
+
"the instance identity file is missing",
|
|
575
|
+
"instance.md says what this SoR is authoritative for — without it nothing states the record's scope, and the future agent surface has no system prompt",
|
|
576
|
+
"restore instance.md from git history, or run the intake-interview skill to write it",
|
|
577
|
+
);
|
|
578
|
+
} else {
|
|
579
|
+
const fm = parseFrontmatter(readFileSync(instanceMd, "utf8"));
|
|
580
|
+
if (fm === null) {
|
|
581
|
+
problem(
|
|
582
|
+
"instance.md",
|
|
583
|
+
"no frontmatter",
|
|
584
|
+
"the format stamp is how any ksor version knows how to read this project",
|
|
585
|
+
"start the file with ---\\nformat: 1\\nname: <this-sor>\\n---",
|
|
586
|
+
);
|
|
587
|
+
} else if (fm.malformed.length > 0) {
|
|
588
|
+
problem(
|
|
589
|
+
"instance.md",
|
|
590
|
+
`unclosed or malformed frontmatter — this is not a frontmatter line: "${fm.malformed[0]}"`,
|
|
591
|
+
"an unclosed block swallows the identity prose and turns it into unreadable configuration",
|
|
592
|
+
"close the block with --- on its own line; every line inside it is `key: value` — the identity prose belongs below it",
|
|
593
|
+
);
|
|
594
|
+
} else {
|
|
595
|
+
// The same YAML-shape rules the record's documents get: a duplicated
|
|
596
|
+
// name: here passed while the shells published the OTHER occurrence
|
|
597
|
+
// (review finding, 2026-08-18).
|
|
598
|
+
for (const line of fm.tightColons) {
|
|
599
|
+
problem(
|
|
600
|
+
"instance.md",
|
|
601
|
+
`missing space after the colon: ${line}`,
|
|
602
|
+
"YAML needs `key: value` — without the space the build fails after this check passed",
|
|
603
|
+
"add a space after the colon",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
for (const line of fm.tabIndents) {
|
|
607
|
+
problem(
|
|
608
|
+
"instance.md",
|
|
609
|
+
`tab-indented frontmatter: ${JSON.stringify(line)}`,
|
|
610
|
+
"YAML refuses tabs as indentation — the build fails after this check passed",
|
|
611
|
+
"indent with spaces",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
for (const dup of fm.duplicates) {
|
|
615
|
+
problem(
|
|
616
|
+
"instance.md",
|
|
617
|
+
`duplicate frontmatter key: ${dup}`,
|
|
618
|
+
"YAML refuses a repeated key — and the surfaces publish one occurrence while this check validated the other",
|
|
619
|
+
`keep one ${dup}: line`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
for (const [key, raw] of fm.malformedQuote) {
|
|
623
|
+
problem(
|
|
624
|
+
"instance.md",
|
|
625
|
+
`frontmatter quoting is malformed: ${key}: ${raw}`,
|
|
626
|
+
"the value starts like a quoted string but is not one clean quoted string — YAML refuses it",
|
|
627
|
+
`quote the whole value exactly once: ${key}: "..."`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const format = fm.keys.get("format");
|
|
631
|
+
if (format === undefined) {
|
|
632
|
+
problem(
|
|
633
|
+
"instance.md",
|
|
634
|
+
"missing frontmatter key: format",
|
|
635
|
+
"the format stamp is how any ksor version knows how to read this project",
|
|
636
|
+
"add format: 1",
|
|
637
|
+
);
|
|
638
|
+
} else if (format !== "1") {
|
|
639
|
+
problem(
|
|
640
|
+
"instance.md",
|
|
641
|
+
`format "${format}" is not 1`,
|
|
642
|
+
"format 1 is the only shape this project's tooling can read",
|
|
643
|
+
"set format: 1 (a newer format means you need a newer ksor)",
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const instanceName = fm.keys.get("name") ?? "";
|
|
647
|
+
if (instanceName === "") {
|
|
648
|
+
problem(
|
|
649
|
+
"instance.md",
|
|
650
|
+
"missing frontmatter key: name",
|
|
651
|
+
"the name identifies this SoR to agents — it is the authority in every future citation",
|
|
652
|
+
"add name: <this-sor>",
|
|
653
|
+
);
|
|
654
|
+
} else if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(instanceName)) {
|
|
655
|
+
// The same grammar `ksor init` enforces at birth: round 3 made this
|
|
656
|
+
// file the single identity source for every surface, so the guard on
|
|
657
|
+
// it has to hold for life, not only at init (review finding,
|
|
658
|
+
// 2026-08-18: an edited name published exactly what init refuses).
|
|
659
|
+
problem(
|
|
660
|
+
"instance.md",
|
|
661
|
+
`name "${instanceName}" does not match ^[a-z0-9][a-z0-9-]{0,62}$`,
|
|
662
|
+
"the name is the future ksor://<name>/ authority and every surface's identity — the grammar that binds it at init binds it forever",
|
|
663
|
+
"use ascii lowercase letters, digits and hyphens",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
for (const key of fm.keys.keys()) {
|
|
667
|
+
if (!INSTANCE_KEYS.has(key)) {
|
|
668
|
+
problem(
|
|
669
|
+
"instance.md",
|
|
670
|
+
`unknown top-level key: ${key}`,
|
|
671
|
+
"the instance key set is closed so a key never means two things — and a misspelled key must never be silently ignored",
|
|
672
|
+
`remove "${key}:" (allowed: ${[...INSTANCE_KEYS].join(", ")}); identity prose belongs in the body, below the frontmatter`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const [parent, allowed] of [
|
|
677
|
+
["ksor", INSTANCE_KSOR_KEYS],
|
|
678
|
+
["site", INSTANCE_SITE_KEYS],
|
|
679
|
+
]) {
|
|
680
|
+
for (const key of fm.children.get(parent)?.keys() ?? []) {
|
|
681
|
+
if (!allowed.has(key)) {
|
|
682
|
+
problem(
|
|
683
|
+
"instance.md",
|
|
684
|
+
`unknown key under ${parent}: ${key}`,
|
|
685
|
+
"the instance key set is closed at every level — an ignored key is a setting the owner believes is in effect",
|
|
686
|
+
`remove "${key}:" (allowed under ${parent}: ${[...allowed].join(", ")})`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// structure: pointer intact, skill copies identical, no content in the site
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
const claudeMd = path.join(root, "CLAUDE.md");
|
|
698
|
+
if (!existsSync(claudeMd) || readFileSync(claudeMd, "utf8").trim() !== "@AGENTS.md") {
|
|
699
|
+
problem(
|
|
700
|
+
"CLAUDE.md",
|
|
701
|
+
"the pointer file changed",
|
|
702
|
+
"AGENTS.md is the single contract; a pointer that grows content forks it",
|
|
703
|
+
"restore CLAUDE.md to exactly one line: @AGENTS.md",
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const agentsSkills = path.join(root, ".agents", "skills");
|
|
708
|
+
const claudeSkills = path.join(root, ".claude", "skills");
|
|
709
|
+
if (existsSync(agentsSkills)) {
|
|
710
|
+
for (const skill of readdirSync(agentsSkills)) {
|
|
711
|
+
const canonical = path.join(agentsSkills, skill);
|
|
712
|
+
const copy = path.join(claudeSkills, skill);
|
|
713
|
+
if (!existsSync(copy)) {
|
|
714
|
+
problem(
|
|
715
|
+
`.claude/skills/${skill}`,
|
|
716
|
+
"missing skill copy",
|
|
717
|
+
"Claude Code reads .claude/skills; a skill without its copy is invisible there",
|
|
718
|
+
`copy .agents/skills/${skill} to .claude/skills/${skill}`,
|
|
719
|
+
);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
for (const file of walkFiles(canonical)) {
|
|
723
|
+
const relFile = path.relative(canonical, file);
|
|
724
|
+
const twin = path.join(copy, relFile);
|
|
725
|
+
if (!existsSync(twin) || !readFileSync(file).equals(readFileSync(twin))) {
|
|
726
|
+
problem(
|
|
727
|
+
`.claude/skills/${skill}/${relFile}`,
|
|
728
|
+
"skill copy differs from the canonical .agents/skills version",
|
|
729
|
+
"two diverging copies means agents follow different rules by tool",
|
|
730
|
+
`re-copy .agents/skills/${skill} over .claude/skills/${skill}`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// The mirror holds in both directions: a file only Claude Code can see is an
|
|
737
|
+
// instruction no other agent obeys and no reviewer reads twice.
|
|
738
|
+
if (existsSync(claudeSkills)) {
|
|
739
|
+
for (const file of walkFiles(claudeSkills)) {
|
|
740
|
+
const relFile = path.relative(claudeSkills, file);
|
|
741
|
+
if (!existsSync(path.join(agentsSkills, relFile))) {
|
|
742
|
+
problem(
|
|
743
|
+
`.claude/skills/${relFile.split(path.sep).join("/")}`,
|
|
744
|
+
"file exists only under .claude/skills",
|
|
745
|
+
".agents/skills is canonical — anything only the copy carries is a rule that never went through review",
|
|
746
|
+
`delete it, or add it to .agents/skills/${relFile.split(path.sep).join("/")} and re-copy the tree`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const siteDir = path.join(root, "system", "site");
|
|
753
|
+
if (existsSync(siteDir)) {
|
|
754
|
+
const offenders = walkFiles(siteDir)
|
|
755
|
+
.filter((p) => !p.includes(`${path.sep}node_modules${path.sep}`))
|
|
756
|
+
.filter(
|
|
757
|
+
(p) =>
|
|
758
|
+
!p.includes(`${path.sep}.next${path.sep}`) &&
|
|
759
|
+
!p.includes(`${path.sep}.source${path.sep}`) &&
|
|
760
|
+
!p.includes(`${path.sep}out${path.sep}`),
|
|
761
|
+
)
|
|
762
|
+
.filter((p) => p.toLowerCase().endsWith(".md") || p.toLowerCase().endsWith(".mdx"));
|
|
763
|
+
for (const p of offenders) {
|
|
764
|
+
problem(
|
|
765
|
+
path.relative(root, p),
|
|
766
|
+
"content file inside the site",
|
|
767
|
+
"the site renders the record; it never holds it — content here silently forks the record",
|
|
768
|
+
"move the content to knowledge/ and delete this file",
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (problems.length > 0) {
|
|
774
|
+
console.error(`format-checker: ${problems.length} problem(s):\n`);
|
|
775
|
+
for (const p of problems) console.error(` ${p}\n`);
|
|
776
|
+
// exitCode, never exit(): exit() drops queued pipe writes, truncating the
|
|
777
|
+
// report mid-word for any reader slower than a file (review finding,
|
|
778
|
+
// 2026-08-18 — 800 problems arrived as 309 through a pipe).
|
|
779
|
+
process.exitCode = 1;
|
|
780
|
+
} else {
|
|
781
|
+
console.log("format-checker: ok — the record is well-formed");
|
|
782
|
+
}
|