@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/package.json +60 -0
- package/src/enum-drift-grammar.js +565 -0
- package/src/enum-drift.js +315 -0
- package/src/index.js +9 -0
- package/src/instructions.js +394 -0
- package/src/invariant-kit.js +487 -0
- package/src/invariants.js +164 -0
- package/src/jtbd.js +517 -0
|
@@ -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
|
+
}
|