@forwardimpact/libinvariant 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ import { resolve } from "node:path";
2
+ import { runRules } from "@forwardimpact/libutil";
3
+
4
+ const SKIP_DIRS = new Set([
5
+ ".cache",
6
+ ".git",
7
+ "build",
8
+ "dist",
9
+ "generated",
10
+ "node_modules",
11
+ "tmp",
12
+ "wiki",
13
+ "worktrees",
14
+ ]);
15
+
16
+ const L7_MAX_ITEMS = 9;
17
+ const L7_MAX_WORDS_PER_ITEM = 32;
18
+
19
+ const CHECKLIST_RE =
20
+ /<(read_do_checklist|do_confirm_checklist)\b[^>]*>([\s\S]*?)<\/\1>/g;
21
+ const ITEM_SPLIT_RE = /^\s*-\s*\[[ xX]\]\s*/m;
22
+
23
+ const lineCount = (text) => (text.match(/\n/g) || []).length;
24
+ const wordCount = (text) => (text.match(/\S+/g) || []).length;
25
+
26
+ // A leading YAML frontmatter block carries metadata, not instruction prose:
27
+ // a skill's `name`/`description`, plus the `license` and `metadata` fields the
28
+ // publish pipeline injects. Exclude it from the line/word budget so a published
29
+ // copy of a layer counts the same as its in-repo source. Only a fenced block
30
+ // that opens on the first line is stripped; the closing fence is the first
31
+ // `---` line that follows.
32
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/;
33
+ const stripFrontmatter = (text) => text.replace(FRONTMATTER_RE, "");
34
+
35
+ async function walk(root, dir, visit, fs) {
36
+ let entries;
37
+ try {
38
+ entries = await fs.readdir(resolve(root, dir), { withFileTypes: true });
39
+ } catch {
40
+ return;
41
+ }
42
+ for (const e of entries) {
43
+ if (SKIP_DIRS.has(e.name)) continue;
44
+ const path = dir === "." ? e.name : `${dir}/${e.name}`;
45
+ await visit(e, path);
46
+ if (e.isDirectory()) await walk(root, path, visit, fs);
47
+ }
48
+ }
49
+
50
+ async function listFiles(root, dir, match, fs) {
51
+ try {
52
+ const entries = await fs.readdir(resolve(root, dir), {
53
+ withFileTypes: true,
54
+ });
55
+ return entries.filter(match).map((e) => `${dir}/${e.name}`);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ async function readText(root, path, fs) {
62
+ try {
63
+ return await fs.readFile(resolve(root, path), "utf8");
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ async function findByName(root, name, kind, fs) {
70
+ const out = [];
71
+ await walk(
72
+ root,
73
+ ".",
74
+ (e, path) => {
75
+ const isMatch = kind === "file" ? e.isFile() : e.isDirectory();
76
+ if (isMatch && e.name === name) out.push(path);
77
+ },
78
+ fs,
79
+ );
80
+ return out;
81
+ }
82
+
83
+ // A `.claude/agents/*.md` file is a profile when it carries both `name` and
84
+ // `description` frontmatter — the same test Claude Code's agent loader applies
85
+ // to decide what loads as an agent — and a reference otherwise. This replaces
86
+ // the old references-subdirectory marker, which APM flattens away.
87
+ const isProfile = (text) =>
88
+ /^name:[ \t]*\S/m.test(text) && /^description:[ \t]*\S/m.test(text);
89
+
90
+ /**
91
+ * Partition the flat `agents/*.md` listing into profiles (L3) and references
92
+ * (L4) by frontmatter. Reads each file once and shares the read between the
93
+ * two layers, replacing the former separate directory walks.
94
+ */
95
+ async function partitionAgents(root, claudeDirs, fs) {
96
+ const profiles = [];
97
+ const references = [];
98
+ for (const d of claudeDirs) {
99
+ const files = await listFiles(
100
+ root,
101
+ `${d}/agents`,
102
+ (e) => e.isFile() && e.name.endsWith(".md"),
103
+ fs,
104
+ );
105
+ for (const path of files) {
106
+ const text = await readText(root, path, fs);
107
+ (text && isProfile(text) ? profiles : references).push(path);
108
+ }
109
+ }
110
+ return { profiles, references };
111
+ }
112
+
113
+ async function findSkillDirs(root, claudeDirs, fs) {
114
+ const out = [];
115
+ for (const d of claudeDirs) {
116
+ const dirs = await listFiles(
117
+ root,
118
+ `${d}/skills`,
119
+ (e) => e.isDirectory(),
120
+ fs,
121
+ );
122
+ out.push(...dirs);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ async function findSkillReferences(root, skillDirs, fs) {
128
+ const out = [];
129
+ for (const d of skillDirs) {
130
+ const files = await listFiles(
131
+ root,
132
+ `${d}/references`,
133
+ (e) => e.isFile() && e.name.endsWith(".md"),
134
+ fs,
135
+ );
136
+ out.push(...files);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ async function buildLayers(root, fs) {
142
+ const claudeDirs = await findByName(root, ".claude", "dir", fs);
143
+ const skillDirs = await findSkillDirs(root, claudeDirs, fs);
144
+ const allClaude = await findByName(root, "CLAUDE.md", "file", fs);
145
+ const rootClaude = allClaude.filter((p) => p === "CLAUDE.md");
146
+ const subdirClaude = allClaude.filter((p) => p !== "CLAUDE.md");
147
+ const { profiles: agentProfiles, references: agentReferences } =
148
+ await partitionAgents(root, claudeDirs, fs);
149
+ return {
150
+ skillDirs,
151
+ layers: [
152
+ {
153
+ id: "L1",
154
+ name: "root CLAUDE.md",
155
+ maxLines: 192,
156
+ maxWords: 896,
157
+ files: rootClaude,
158
+ },
159
+ {
160
+ id: "L1",
161
+ name: "subdir CLAUDE.md",
162
+ maxLines: 128,
163
+ maxWords: 768,
164
+ files: subdirClaude,
165
+ },
166
+ {
167
+ id: "L2",
168
+ name: "CONTRIBUTING.md",
169
+ maxLines: 320,
170
+ maxWords: 1664,
171
+ files: ["CONTRIBUTING.md"],
172
+ },
173
+ {
174
+ id: "L2",
175
+ name: "JTBD.md",
176
+ maxLines: 320,
177
+ // Larger than the L2 default to absorb a fifth persona block.
178
+ maxWords: 1664,
179
+ files: ["JTBD.md"],
180
+ },
181
+ {
182
+ id: "L3",
183
+ name: "agent profile",
184
+ maxLines: 72,
185
+ maxWords: 448,
186
+ files: agentProfiles,
187
+ },
188
+ {
189
+ id: "L4",
190
+ name: "agent reference",
191
+ maxLines: 192,
192
+ maxWords: 1280,
193
+ files: agentReferences.filter(
194
+ (p) => !p.endsWith("/agents/x-memory-protocol.md"),
195
+ ),
196
+ },
197
+ {
198
+ id: "L4",
199
+ name: "memory-protocol agent reference",
200
+ // Larger than the L4 default to absorb the two durable surfaces this
201
+ // one reference is the sole home for: the boot-digest routing contract
202
+ // (materialized agent-experiments surface with provenance fields and a
203
+ // last-successful-sync freshness bound, plus the verbatim
204
+ // standing-carries digest field) and the canonical Carry Surface
205
+ // section (a durable per-Assess obligation surface kept off the summary
206
+ // budget, beside the On-Boot Read Set it extends). Both are load-bearing
207
+ // memory-protocol concepts. Sized to the current content, not
208
+ // open-ended.
209
+ maxLines: 216,
210
+ maxWords: 1588,
211
+ files: agentReferences.filter((p) =>
212
+ p.endsWith("/agents/x-memory-protocol.md"),
213
+ ),
214
+ },
215
+ {
216
+ id: "L5",
217
+ name: "skill procedure",
218
+ maxLines: 192,
219
+ maxWords: 1280,
220
+ files: skillDirs
221
+ .map((d) => `${d}/SKILL.md`)
222
+ .filter((p) => !p.endsWith("/kata-release-merge/SKILL.md")),
223
+ },
224
+ {
225
+ id: "L5",
226
+ name: "kata-release-merge skill procedure",
227
+ // Larger than the L5 default to absorb four consolidated merge-gate
228
+ // rule sets that govern adjacent corners of one gate: phase-PR review
229
+ // transfer (pin-based head coverage), post-panel coverage (folded into
230
+ // the pin mechanism rather than duplicated), the spec-less
231
+ // experiment-PR approval path, and the block-comment re-ping cadence.
232
+ // Sized to the consolidated content, not open-ended.
233
+ maxLines: 320,
234
+ maxWords: 2304,
235
+ files: skillDirs
236
+ .map((d) => `${d}/SKILL.md`)
237
+ .filter((p) => p.endsWith("/kata-release-merge/SKILL.md")),
238
+ },
239
+ {
240
+ id: "L6",
241
+ name: "skill reference",
242
+ maxLines: 128,
243
+ maxWords: 768,
244
+ files: await findSkillReferences(root, skillDirs, fs),
245
+ },
246
+ ],
247
+ };
248
+ }
249
+
250
+ function offsetToLine(text, offset) {
251
+ let line = 1;
252
+ for (let i = 0; i < offset && i < text.length; i++) {
253
+ if (text.charCodeAt(i) === 10) line++;
254
+ }
255
+ return line;
256
+ }
257
+
258
+ // -- Subject builders ----------------------------------------------------
259
+
260
+ async function buildFileSubjects(root, layers, fs) {
261
+ const subjects = [];
262
+ for (const layer of layers) {
263
+ for (const relPath of layer.files) {
264
+ const text = await readText(root, relPath, fs);
265
+ if (text == null) continue;
266
+ // Budget the instruction prose only — metadata frontmatter is exempt.
267
+ const body = stripFrontmatter(text);
268
+ subjects.push({
269
+ path: resolve(root, relPath),
270
+ layer: { id: layer.id, name: layer.name },
271
+ lines: lineCount(body),
272
+ words: wordCount(body),
273
+ maxLines: layer.maxLines,
274
+ maxWords: layer.maxWords,
275
+ });
276
+ }
277
+ }
278
+ return subjects;
279
+ }
280
+
281
+ async function buildChecklistSubjects(root, sources, fs) {
282
+ const subjects = [];
283
+ for (const relPath of sources) {
284
+ const text = await readText(root, relPath, fs);
285
+ if (text == null) continue;
286
+ const absPath = resolve(root, relPath);
287
+ CHECKLIST_RE.lastIndex = 0;
288
+ let m;
289
+ let blockIndex = 0;
290
+ while ((m = CHECKLIST_RE.exec(text))) {
291
+ blockIndex += 1;
292
+ const items = m[2].split(ITEM_SPLIT_RE).slice(1);
293
+ subjects.push({
294
+ path: absPath,
295
+ lineNo: offsetToLine(text, m.index),
296
+ type: m[1],
297
+ blockIndex,
298
+ items: items.map((raw) => ({ words: wordCount(raw.trim()) })),
299
+ });
300
+ }
301
+ }
302
+ return subjects;
303
+ }
304
+
305
+ const HINT_LAYER_BUDGET =
306
+ "trim prose to fit the layer cap — see JIDOKA.md for the layered-instruction model";
307
+
308
+ // -- Rule catalogue ------------------------------------------------------
309
+
310
+ export const INSTRUCTION_RULES = [
311
+ {
312
+ id: "instructions.line-budget",
313
+ scope: "instruction-file",
314
+ severity: "fail",
315
+ check: (s) =>
316
+ s.lines > s.maxLines ? { value: s.lines, max: s.maxLines } : null,
317
+ message: (s, r) => `${r.value} lines (max ${r.max}, ${s.layer.name})`,
318
+ hint: HINT_LAYER_BUDGET,
319
+ },
320
+ {
321
+ id: "instructions.word-budget",
322
+ scope: "instruction-file",
323
+ severity: "fail",
324
+ check: (s) =>
325
+ s.words > s.maxWords ? { value: s.words, max: s.maxWords } : null,
326
+ message: (s, r) => `${r.value} words (max ${r.max}, ${s.layer.name})`,
327
+ hint: HINT_LAYER_BUDGET,
328
+ },
329
+ {
330
+ id: "L7.too-many-items",
331
+ scope: "checklist-block",
332
+ severity: "fail",
333
+ check: (s) =>
334
+ s.items.length > L7_MAX_ITEMS
335
+ ? { count: s.items.length, max: L7_MAX_ITEMS }
336
+ : null,
337
+ message: (s, r) =>
338
+ `checklist #${s.blockIndex} (${s.type}) has ${r.count} items (max ${r.max})`,
339
+ hint: "split the checklist into multiple sections, or remove items not load-bearing for the goal",
340
+ },
341
+ {
342
+ id: "L7.item-too-many-words",
343
+ scope: "checklist-block",
344
+ severity: "fail",
345
+ check: (s) => {
346
+ const offenders = [];
347
+ s.items.forEach((item, i) => {
348
+ if (item.words > L7_MAX_WORDS_PER_ITEM) {
349
+ offenders.push({
350
+ itemIndex: i + 1,
351
+ words: item.words,
352
+ max: L7_MAX_WORDS_PER_ITEM,
353
+ });
354
+ }
355
+ });
356
+ return offenders.length === 0 ? null : offenders;
357
+ },
358
+ message: (s, r) =>
359
+ `checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
360
+ hint: "rewrite the item more concisely — checklist items are pointers, not explanations",
361
+ },
362
+ ];
363
+
364
+ // -- Public entry --------------------------------------------------------
365
+
366
+ /**
367
+ * Walk the repo rooted at `root`, applying the L1–L7 caps from JIDOKA.md.
368
+ * Each layer is gated by a line cap AND a word cap; either breach fails.
369
+ *
370
+ * @param {{ root: string, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
371
+ * @returns {Promise<Finding[]>} Structured findings; empty when conformant.
372
+ * Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
373
+ * with `emitFindingsText` / `emitFindingsJson` from libutil.
374
+ */
375
+ export async function checkInstructions({ root, runtime }) {
376
+ if (!runtime) throw new Error("runtime is required");
377
+ const { fs } = runtime;
378
+ const { layers, skillDirs } = await buildLayers(root, fs);
379
+ const fileSubjects = await buildFileSubjects(root, layers, fs);
380
+ const checklistSubjects = await buildChecklistSubjects(
381
+ root,
382
+ ["CONTRIBUTING.md", ...skillDirs.map((d) => `${d}/SKILL.md`)],
383
+ fs,
384
+ );
385
+
386
+ const ctx = {
387
+ subjects: {
388
+ "instruction-file": fileSubjects,
389
+ "checklist-block": checklistSubjects,
390
+ },
391
+ };
392
+ const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
393
+ return runRules(INSTRUCTION_RULES, ctx, { resolveScope });
394
+ }