@forwardimpact/libcoaligned 0.1.4 → 0.1.6
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/README.md +4 -3
- package/bin/coaligned.js +28 -15
- package/package.json +2 -1
- package/src/instructions.js +141 -47
- package/src/jtbd.js +142 -63
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ invariants across the repo.
|
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
13
|
npx coaligned # run every check (instructions + jtbd)
|
|
14
|
-
npx coaligned instructions # enforce L1–
|
|
14
|
+
npx coaligned instructions # enforce L1–L7 length and checklist caps
|
|
15
15
|
npx coaligned jtbd # validate JTBD entries against package.json
|
|
16
16
|
npx coaligned jtbd --fix # regenerate catalog and job blocks in place
|
|
17
17
|
```
|
|
@@ -20,8 +20,9 @@ The two subcommands implement the contract described in
|
|
|
20
20
|
[COALIGNED.md](https://github.com/forwardimpact/monorepo/blob/main/COALIGNED.md):
|
|
21
21
|
|
|
22
22
|
- `instructions` — every layer (L1 CLAUDE.md, L2 CONTRIBUTING.md / JTBD.md,
|
|
23
|
-
L3 agent profile, L4
|
|
24
|
-
a line cap **and** a word cap. Either breach
|
|
23
|
+
L3 agent profile, L4 agent reference, L5 SKILL.md, L6 skill reference,
|
|
24
|
+
L7 checklist block) is gated by a line cap **and** a word cap. Either breach
|
|
25
|
+
fails.
|
|
25
26
|
- `jtbd` — each `package.json .jobs` entry is validated against the JTBD
|
|
26
27
|
schema; with `--fix`, marker-delimited blocks in `<dir>/README.md`,
|
|
27
28
|
`<dir>/<pkg>/README.md`, and root `JTBD.md` are regenerated.
|
package/bin/coaligned.js
CHANGED
|
@@ -4,6 +4,7 @@ import "@forwardimpact/libpreflight/node22";
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { createCli } from "@forwardimpact/libcli";
|
|
7
|
+
import { emitFindingsJson, emitFindingsText } from "@forwardimpact/libutil";
|
|
7
8
|
import { checkInstructions, checkJtbd } from "../src/index.js";
|
|
8
9
|
|
|
9
10
|
const { version: VERSION } = JSON.parse(
|
|
@@ -40,37 +41,48 @@ const definition = {
|
|
|
40
41
|
globalOptions: {
|
|
41
42
|
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
42
43
|
version: { type: "boolean", description: "Show version" },
|
|
43
|
-
json: { type: "boolean", description: "Output
|
|
44
|
+
json: { type: "boolean", description: "Output findings as JSON" },
|
|
44
45
|
},
|
|
45
46
|
examples: ["coaligned", "coaligned instructions", "coaligned jtbd --fix"],
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const cli = createCli(definition);
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
function writeFindings(findings, passMessage, jsonOutput, cwd) {
|
|
52
|
+
if (jsonOutput) {
|
|
53
|
+
process.stdout.write(emitFindingsJson(findings));
|
|
54
|
+
} else if (findings.length > 0) {
|
|
55
|
+
process.stderr.write(emitFindingsText(findings, { cwd, passMessage }));
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write(emitFindingsText(findings, { cwd, passMessage }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runInstructions(root, jsonOutput) {
|
|
62
|
+
const findings = await checkInstructions({ root });
|
|
63
|
+
writeFindings(findings, "coaligned instructions passed", jsonOutput, root);
|
|
64
|
+
return findings.length > 0 ? 1 : 0;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
async function runJtbd(root, fix) {
|
|
57
|
-
const {
|
|
58
|
-
|
|
67
|
+
async function runJtbd(root, fix, jsonOutput) {
|
|
68
|
+
const { findings, stale, fixed } = await checkJtbd({ root, fix });
|
|
69
|
+
writeFindings(findings, "coaligned jtbd passed", jsonOutput, root);
|
|
59
70
|
for (const f of fixed) process.stdout.write(`Regenerated ${f}.\n`);
|
|
60
|
-
|
|
71
|
+
if (stale.length > 0 && !jsonOutput) {
|
|
61
72
|
process.stderr.write(
|
|
62
|
-
|
|
73
|
+
`\n${stale.length} file${stale.length === 1 ? "" : "s"} out of date — run \`coaligned jtbd --fix\` to regenerate:\n`,
|
|
63
74
|
);
|
|
75
|
+
for (const s of stale) process.stderr.write(` - ${s}\n`);
|
|
64
76
|
}
|
|
65
|
-
return
|
|
77
|
+
return findings.length > 0 || stale.length > 0 ? 1 : 0;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
async function instructionsHandler(ctx) {
|
|
69
|
-
return runInstructions(ctx.data.root);
|
|
81
|
+
return runInstructions(ctx.data.root, !!ctx.options.json);
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
async function jtbdHandler(ctx) {
|
|
73
|
-
return runJtbd(ctx.data.root, !!ctx.options.fix);
|
|
85
|
+
return runJtbd(ctx.data.root, !!ctx.options.fix, !!ctx.options.json);
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
async function main() {
|
|
@@ -78,12 +90,13 @@ async function main() {
|
|
|
78
90
|
if (!parsed) return 0;
|
|
79
91
|
|
|
80
92
|
const root = process.cwd();
|
|
93
|
+
const jsonOutput = !!parsed.values.json;
|
|
81
94
|
|
|
82
95
|
// No subcommand → run every check; --fix stays jtbd-only and must be opted
|
|
83
96
|
// into explicitly via `coaligned jtbd --fix`.
|
|
84
97
|
if (parsed.positionals.length === 0) {
|
|
85
|
-
const a = await runInstructions(root);
|
|
86
|
-
const b = await runJtbd(root, false);
|
|
98
|
+
const a = await runInstructions(root, jsonOutput);
|
|
99
|
+
const b = await runJtbd(root, false, jsonOutput);
|
|
87
100
|
return a || b;
|
|
88
101
|
}
|
|
89
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libcoaligned",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Co-Aligned architecture checks — enforce instruction-layer length caps and JTBD invariants across the repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coaligned",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@forwardimpact/libcli": "^0.1.9",
|
|
49
49
|
"@forwardimpact/libpreflight": "^0.1.0",
|
|
50
|
+
"@forwardimpact/libutil": "*",
|
|
50
51
|
"prettier": "^3.0.0"
|
|
51
52
|
},
|
|
52
53
|
"engines": {
|
package/src/instructions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
3
4
|
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
".cache",
|
|
@@ -13,8 +14,8 @@ const SKIP_DIRS = new Set([
|
|
|
13
14
|
"worktrees",
|
|
14
15
|
]);
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
17
|
+
const L7_MAX_ITEMS = 9;
|
|
18
|
+
const L7_MAX_WORDS_PER_ITEM = 32;
|
|
18
19
|
|
|
19
20
|
const CHECKLIST_RE =
|
|
20
21
|
/<(read_do_checklist|do_confirm_checklist)\b[^>]*>([\s\S]*?)<\/\1>/g;
|
|
@@ -77,6 +78,19 @@ async function findAgentProfiles(root, claudeDirs) {
|
|
|
77
78
|
return out;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
async function findAgentReferences(root, claudeDirs) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const d of claudeDirs) {
|
|
84
|
+
const files = await listFiles(
|
|
85
|
+
root,
|
|
86
|
+
`${d}/agents/references`,
|
|
87
|
+
(e) => e.isFile() && e.name.endsWith(".md"),
|
|
88
|
+
);
|
|
89
|
+
out.push(...files);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
async function findSkillDirs(root, claudeDirs) {
|
|
81
95
|
const out = [];
|
|
82
96
|
for (const d of claudeDirs) {
|
|
@@ -133,7 +147,7 @@ async function buildLayers(root) {
|
|
|
133
147
|
id: "L2",
|
|
134
148
|
name: "JTBD.md",
|
|
135
149
|
maxLines: 320,
|
|
136
|
-
//
|
|
150
|
+
// Larger than the L2 default to absorb a fifth persona block.
|
|
137
151
|
maxWords: 1664,
|
|
138
152
|
files: ["JTBD.md"],
|
|
139
153
|
},
|
|
@@ -146,13 +160,20 @@ async function buildLayers(root) {
|
|
|
146
160
|
},
|
|
147
161
|
{
|
|
148
162
|
id: "L4",
|
|
163
|
+
name: "agent reference",
|
|
164
|
+
maxLines: 192,
|
|
165
|
+
maxWords: 1280,
|
|
166
|
+
files: await findAgentReferences(root, claudeDirs),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "L5",
|
|
149
170
|
name: "skill procedure",
|
|
150
171
|
maxLines: 192,
|
|
151
172
|
maxWords: 1280,
|
|
152
173
|
files: skillDirs.map((d) => `${d}/SKILL.md`),
|
|
153
174
|
},
|
|
154
175
|
{
|
|
155
|
-
id: "
|
|
176
|
+
id: "L6",
|
|
156
177
|
name: "skill reference",
|
|
157
178
|
maxLines: 128,
|
|
158
179
|
maxWords: 768,
|
|
@@ -162,70 +183,143 @@ async function buildLayers(root) {
|
|
|
162
183
|
};
|
|
163
184
|
}
|
|
164
185
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (text
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
);
|
|
186
|
+
function offsetToLine(text, offset) {
|
|
187
|
+
let line = 1;
|
|
188
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
189
|
+
if (text.charCodeAt(i) === 10) line++;
|
|
190
|
+
}
|
|
191
|
+
return line;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -- Subject builders ----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
async function buildFileSubjects(root, layers) {
|
|
197
|
+
const subjects = [];
|
|
198
|
+
for (const layer of layers) {
|
|
199
|
+
for (const relPath of layer.files) {
|
|
200
|
+
const text = await readText(root, relPath);
|
|
201
|
+
if (text == null) continue;
|
|
202
|
+
subjects.push({
|
|
203
|
+
path: resolve(root, relPath),
|
|
204
|
+
layer: { id: layer.id, name: layer.name },
|
|
205
|
+
lines: lineCount(text),
|
|
206
|
+
words: wordCount(text),
|
|
207
|
+
maxLines: layer.maxLines,
|
|
208
|
+
maxWords: layer.maxWords,
|
|
209
|
+
});
|
|
180
210
|
}
|
|
181
211
|
}
|
|
212
|
+
return subjects;
|
|
182
213
|
}
|
|
183
214
|
|
|
184
|
-
async function
|
|
185
|
-
|
|
186
|
-
|
|
215
|
+
async function buildChecklistSubjects(root, sources) {
|
|
216
|
+
const subjects = [];
|
|
217
|
+
for (const relPath of sources) {
|
|
218
|
+
const text = await readText(root, relPath);
|
|
187
219
|
if (text == null) continue;
|
|
220
|
+
const absPath = resolve(root, relPath);
|
|
221
|
+
CHECKLIST_RE.lastIndex = 0;
|
|
188
222
|
let m;
|
|
189
|
-
let
|
|
223
|
+
let blockIndex = 0;
|
|
190
224
|
while ((m = CHECKLIST_RE.exec(text))) {
|
|
191
|
-
|
|
192
|
-
const type = m[1];
|
|
225
|
+
blockIndex += 1;
|
|
193
226
|
const items = m[2].split(ITEM_SPLIT_RE).slice(1);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const w = wordCount(raw.trim());
|
|
201
|
-
if (w > L6_MAX_WORDS_PER_ITEM) {
|
|
202
|
-
errors.push(
|
|
203
|
-
`${path} checklist #${index} (${type}) item ${i + 1} has ${w} words (max ${L6_MAX_WORDS_PER_ITEM}, L6 checklist item)`,
|
|
204
|
-
);
|
|
205
|
-
}
|
|
227
|
+
subjects.push({
|
|
228
|
+
path: absPath,
|
|
229
|
+
lineNo: offsetToLine(text, m.index),
|
|
230
|
+
type: m[1],
|
|
231
|
+
blockIndex,
|
|
232
|
+
items: items.map((raw) => ({ words: wordCount(raw.trim()) })),
|
|
206
233
|
});
|
|
207
234
|
}
|
|
208
235
|
}
|
|
236
|
+
return subjects;
|
|
209
237
|
}
|
|
210
238
|
|
|
239
|
+
const HINT_LAYER_BUDGET =
|
|
240
|
+
"trim prose to fit the layer cap — see COALIGNED.md for the layered-instruction model";
|
|
241
|
+
|
|
242
|
+
// -- Rule catalogue ------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export const INSTRUCTION_RULES = [
|
|
245
|
+
{
|
|
246
|
+
id: "instructions.line-budget",
|
|
247
|
+
scope: "instruction-file",
|
|
248
|
+
severity: "fail",
|
|
249
|
+
check: (s) =>
|
|
250
|
+
s.lines > s.maxLines ? { value: s.lines, max: s.maxLines } : null,
|
|
251
|
+
message: (s, r) => `${r.value} lines (max ${r.max}, ${s.layer.name})`,
|
|
252
|
+
hint: HINT_LAYER_BUDGET,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "instructions.word-budget",
|
|
256
|
+
scope: "instruction-file",
|
|
257
|
+
severity: "fail",
|
|
258
|
+
check: (s) =>
|
|
259
|
+
s.words > s.maxWords ? { value: s.words, max: s.maxWords } : null,
|
|
260
|
+
message: (s, r) => `${r.value} words (max ${r.max}, ${s.layer.name})`,
|
|
261
|
+
hint: HINT_LAYER_BUDGET,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: "L7.too-many-items",
|
|
265
|
+
scope: "checklist-block",
|
|
266
|
+
severity: "fail",
|
|
267
|
+
check: (s) =>
|
|
268
|
+
s.items.length > L7_MAX_ITEMS
|
|
269
|
+
? { count: s.items.length, max: L7_MAX_ITEMS }
|
|
270
|
+
: null,
|
|
271
|
+
message: (s, r) =>
|
|
272
|
+
`checklist #${s.blockIndex} (${s.type}) has ${r.count} items (max ${r.max})`,
|
|
273
|
+
hint: "split the checklist into multiple sections, or remove items not load-bearing for the goal",
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: "L7.item-too-many-words",
|
|
277
|
+
scope: "checklist-block",
|
|
278
|
+
severity: "fail",
|
|
279
|
+
check: (s) => {
|
|
280
|
+
const offenders = [];
|
|
281
|
+
s.items.forEach((item, i) => {
|
|
282
|
+
if (item.words > L7_MAX_WORDS_PER_ITEM) {
|
|
283
|
+
offenders.push({
|
|
284
|
+
itemIndex: i + 1,
|
|
285
|
+
words: item.words,
|
|
286
|
+
max: L7_MAX_WORDS_PER_ITEM,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return offenders.length === 0 ? null : offenders;
|
|
291
|
+
},
|
|
292
|
+
message: (s, r) =>
|
|
293
|
+
`checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
|
|
294
|
+
hint: "rewrite the item more concisely — checklist items are pointers, not explanations",
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
// -- Public entry --------------------------------------------------------
|
|
299
|
+
|
|
211
300
|
/**
|
|
212
|
-
* Walk the repo rooted at `root`, applying the L1–
|
|
301
|
+
* Walk the repo rooted at `root`, applying the L1–L7 caps from COALIGNED.md.
|
|
213
302
|
* Each layer is gated by a line cap AND a word cap; either breach fails.
|
|
214
303
|
*
|
|
215
304
|
* @param {{ root: string }} options
|
|
216
|
-
* @returns {Promise<
|
|
305
|
+
* @returns {Promise<Finding[]>} Structured findings; empty when conformant.
|
|
306
|
+
* Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
|
|
307
|
+
* with `emitFindingsText` / `emitFindingsJson` from libutil.
|
|
217
308
|
*/
|
|
218
309
|
export async function checkInstructions({ root }) {
|
|
219
|
-
const errors = [];
|
|
220
310
|
const { layers, skillDirs } = await buildLayers(root);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const checklistSources = [
|
|
311
|
+
const fileSubjects = await buildFileSubjects(root, layers);
|
|
312
|
+
const checklistSubjects = await buildChecklistSubjects(root, [
|
|
225
313
|
"CONTRIBUTING.md",
|
|
226
314
|
...skillDirs.map((d) => `${d}/SKILL.md`),
|
|
227
|
-
];
|
|
228
|
-
await checkChecklists(root, checklistSources, errors);
|
|
315
|
+
]);
|
|
229
316
|
|
|
230
|
-
|
|
317
|
+
const ctx = {
|
|
318
|
+
subjects: {
|
|
319
|
+
"instruction-file": fileSubjects,
|
|
320
|
+
"checklist-block": checklistSubjects,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
|
|
324
|
+
return runRules(INSTRUCTION_RULES, ctx, { resolveScope });
|
|
231
325
|
}
|
package/src/jtbd.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import * as prettier from "prettier";
|
|
4
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
4
5
|
|
|
5
6
|
const VALID_USERS = [
|
|
6
7
|
"Engineering Leaders",
|
|
@@ -55,65 +56,140 @@ function loadPackages(dir, filter) {
|
|
|
55
56
|
return out.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
if (!(entry.user && VALID_USERS.includes(entry.user))) {
|
|
61
|
-
errors.push(
|
|
62
|
-
`${prefix}: invalid user "${entry.user}". Must be one of: ${VALID_USERS.join(", ")}`,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
for (const field of ["goal", "trigger", "competesWith"]) {
|
|
66
|
-
if (!entry[field] || typeof entry[field] !== "string") {
|
|
67
|
-
errors.push(`${prefix}: ${field} is required and must be a string`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
for (const field of ["bigHire", "littleHire"]) {
|
|
71
|
-
if (!entry[field] || typeof entry[field] !== "string") {
|
|
72
|
-
errors.push(`${prefix}: ${field} is required and must be a string`);
|
|
73
|
-
} else if (!entry[field].endsWith(".")) {
|
|
74
|
-
errors.push(`${prefix}: ${field} must end with ".": "${entry[field]}"`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return errors;
|
|
59
|
+
function slot(s) {
|
|
60
|
+
return `.jobs[${s.index}]`;
|
|
78
61
|
}
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
63
|
+
export const JTBD_RULES = [
|
|
64
|
+
{
|
|
65
|
+
id: "jtbd.jobs-must-be-array",
|
|
66
|
+
scope: "package-jobs",
|
|
67
|
+
severity: "fail",
|
|
68
|
+
check: (s) => (Array.isArray(s.jobs) ? null : {}),
|
|
69
|
+
message: () => ".jobs must be an array",
|
|
70
|
+
hint: "wrap the value in [] — even a single job is an array of one",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "jtbd.invalid-user",
|
|
74
|
+
scope: "jtbd-entry",
|
|
75
|
+
severity: "fail",
|
|
76
|
+
check: (s) =>
|
|
77
|
+
s.entry.user && VALID_USERS.includes(s.entry.user)
|
|
78
|
+
? null
|
|
79
|
+
: { user: s.entry.user },
|
|
80
|
+
message: (s, r) => `${slot(s)}: invalid user "${r.user}"`,
|
|
81
|
+
hint: `must be one of: ${VALID_USERS.join(", ")}`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "jtbd.missing-field",
|
|
85
|
+
scope: "jtbd-entry",
|
|
86
|
+
severity: "fail",
|
|
87
|
+
check: (s) => {
|
|
88
|
+
const offenders = [];
|
|
89
|
+
for (const field of [
|
|
90
|
+
"goal",
|
|
91
|
+
"trigger",
|
|
92
|
+
"competesWith",
|
|
93
|
+
"bigHire",
|
|
94
|
+
"littleHire",
|
|
95
|
+
]) {
|
|
96
|
+
if (!s.entry[field] || typeof s.entry[field] !== "string") {
|
|
97
|
+
offenders.push({ field });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return offenders.length === 0 ? null : offenders;
|
|
101
|
+
},
|
|
102
|
+
message: (s, r) =>
|
|
103
|
+
`${slot(s)}: ${r.field} is required and must be a string`,
|
|
104
|
+
hint: "every job entry needs goal, trigger, competesWith, bigHire, and littleHire (hires end with a period)",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "jtbd.hire-missing-period",
|
|
108
|
+
scope: "jtbd-entry",
|
|
109
|
+
severity: "fail",
|
|
110
|
+
check: (s) => {
|
|
111
|
+
const offenders = [];
|
|
112
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
113
|
+
const v = s.entry[field];
|
|
114
|
+
if (typeof v === "string" && v.length > 0 && !v.endsWith(".")) {
|
|
115
|
+
offenders.push({ field, value: v });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return offenders.length === 0 ? null : offenders;
|
|
119
|
+
},
|
|
120
|
+
message: (s, r) =>
|
|
121
|
+
`${slot(s)}: ${r.field} must end with "." — "${r.value}"`,
|
|
122
|
+
hint: "append a period to the hire sentence",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
// Cross-entry uniqueness — mutates ctx.allHires across iterations.
|
|
126
|
+
id: "jtbd.duplicate-hire",
|
|
127
|
+
scope: "jtbd-entry",
|
|
128
|
+
severity: "fail",
|
|
129
|
+
when: (s) => !s.skipUniqueHires,
|
|
130
|
+
check: (s, ctx) => {
|
|
131
|
+
const offenders = [];
|
|
132
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
133
|
+
const v = s.entry[field];
|
|
134
|
+
if (typeof v !== "string" || !v) continue;
|
|
135
|
+
const key = `${field}:${v.toLowerCase()}`;
|
|
136
|
+
const prior = ctx.allHires.get(key);
|
|
137
|
+
if (prior && prior.goal !== s.entry.goal) {
|
|
138
|
+
offenders.push({ field, value: v, otherLoc: prior.loc });
|
|
139
|
+
}
|
|
140
|
+
ctx.allHires.set(key, { loc: s.loc, goal: s.entry.goal });
|
|
141
|
+
}
|
|
142
|
+
return offenders.length === 0 ? null : offenders;
|
|
143
|
+
},
|
|
144
|
+
message: (s, r) =>
|
|
145
|
+
`${slot(s)}: duplicate ${r.field} "${r.value}" (also in ${r.otherLoc})`,
|
|
146
|
+
hint: "merge the duplicate job into a single entry, or differentiate the hire text",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
94
149
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
const
|
|
150
|
+
function buildSubjects(packages, catalogDir, catalogName, skipUniqueHires) {
|
|
151
|
+
const packageSubjects = [];
|
|
152
|
+
const entrySubjects = [];
|
|
98
153
|
for (const { dir, pkg } of packages) {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
}
|
|
154
|
+
const pkgPath = join(catalogDir, dir, "package.json");
|
|
155
|
+
if (pkg.jobs == null) continue;
|
|
156
|
+
packageSubjects.push({ path: pkgPath, jobs: pkg.jobs });
|
|
157
|
+
if (!Array.isArray(pkg.jobs)) continue;
|
|
158
|
+
const loc = `${catalogName}/${dir}`;
|
|
159
|
+
pkg.jobs.forEach((entry, i) => {
|
|
160
|
+
entrySubjects.push({
|
|
161
|
+
path: pkgPath,
|
|
162
|
+
index: i,
|
|
163
|
+
entry,
|
|
164
|
+
loc,
|
|
165
|
+
skipUniqueHires,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
115
168
|
}
|
|
116
|
-
return
|
|
169
|
+
return { packageSubjects, entrySubjects };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validate(
|
|
173
|
+
packages,
|
|
174
|
+
catalogDir,
|
|
175
|
+
catalogName,
|
|
176
|
+
{ skipUniqueHires = false } = {},
|
|
177
|
+
) {
|
|
178
|
+
const { packageSubjects, entrySubjects } = buildSubjects(
|
|
179
|
+
packages,
|
|
180
|
+
catalogDir,
|
|
181
|
+
catalogName,
|
|
182
|
+
skipUniqueHires,
|
|
183
|
+
);
|
|
184
|
+
const ctx = {
|
|
185
|
+
allHires: new Map(),
|
|
186
|
+
subjects: {
|
|
187
|
+
"package-jobs": packageSubjects,
|
|
188
|
+
"jtbd-entry": entrySubjects,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
|
|
192
|
+
return runRules(JTBD_RULES, ctx, { resolveScope });
|
|
117
193
|
}
|
|
118
194
|
|
|
119
195
|
function renderTable(headers, rows) {
|
|
@@ -340,11 +416,11 @@ function commitUpdate(filePath, label, original, updated, fix, result) {
|
|
|
340
416
|
|
|
341
417
|
async function processCatalog(catalog, fix, formatMarkdown, result) {
|
|
342
418
|
const packages = loadPackages(catalog.dir, catalog.filter);
|
|
343
|
-
const
|
|
419
|
+
const findings = validate(packages, catalog.dir, catalog.name, {
|
|
344
420
|
skipUniqueHires: catalog.skipUniqueHires ?? false,
|
|
345
421
|
});
|
|
346
|
-
if (
|
|
347
|
-
result.
|
|
422
|
+
if (findings.length > 0) {
|
|
423
|
+
result.findings.push(...findings);
|
|
348
424
|
return;
|
|
349
425
|
}
|
|
350
426
|
|
|
@@ -394,9 +470,11 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
|
|
|
394
470
|
if (!existsSync(jtbdPath)) return;
|
|
395
471
|
const productsCatalog = catalogs(root).find((c) => c.name === "products");
|
|
396
472
|
const packages = loadPackages(productsCatalog.dir, productsCatalog.filter);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
473
|
+
const findings = validate(packages, productsCatalog.dir, "products", {
|
|
474
|
+
skipUniqueHires: true,
|
|
475
|
+
});
|
|
476
|
+
if (findings.length > 0) {
|
|
477
|
+
if (result.findings.length === 0) result.findings.push(...findings);
|
|
400
478
|
return;
|
|
401
479
|
}
|
|
402
480
|
const original = readFileSync(jtbdPath, "utf8");
|
|
@@ -412,13 +490,14 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
|
|
|
412
490
|
* jobs, and description blocks in the corresponding README.md and JTBD.md.
|
|
413
491
|
*
|
|
414
492
|
* @param {{ root: string, fix?: boolean }} options
|
|
415
|
-
* @returns {Promise<{
|
|
416
|
-
* `
|
|
493
|
+
* @returns {Promise<{ findings: Finding[], stale: string[], fixed: string[] }>}
|
|
494
|
+
* `findings` are validation failures (structured for `emitFindingsText` /
|
|
495
|
+
* `emitFindingsJson` from libutil); `stale` is files whose generated blocks
|
|
417
496
|
* are out of date (only populated when `fix` is false); `fixed` is files
|
|
418
497
|
* that were rewritten in place.
|
|
419
498
|
*/
|
|
420
499
|
export async function checkJtbd({ root, fix = false }) {
|
|
421
|
-
const result = {
|
|
500
|
+
const result = { findings: [], stale: [], fixed: [] };
|
|
422
501
|
const prettierConfig = await prettier.resolveConfig(join(root, "JTBD.md"));
|
|
423
502
|
const formatMarkdown = makeFormatter(prettierConfig);
|
|
424
503
|
|