@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 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–L6 length and checklist caps
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 SKILL.md, L5 reference, L6 checklist block) is gated by
24
- a line cap **and** a word cap. Either breach fails.
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 help as JSON" },
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
- async function runInstructions(root) {
51
- const errors = await checkInstructions({ root });
52
- for (const e of errors) process.stderr.write(`error: ${e}\n`);
53
- return errors.length > 0 ? 1 : 0;
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 { errors, stale, fixed } = await checkJtbd({ root, fix });
58
- for (const e of errors) process.stderr.write(`${e}\n`);
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
- for (const s of stale) {
71
+ if (stale.length > 0 && !jsonOutput) {
61
72
  process.stderr.write(
62
- `${s} out of date. Run \`coaligned jtbd --fix\` to regenerate.\n`,
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 errors.length > 0 || stale.length > 0 ? 1 : 0;
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.4",
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": {
@@ -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 L6_MAX_ITEMS = 9;
17
- const L6_MAX_WORDS_PER_ITEM = 32;
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
- // Bumped from 1408 in spec 1010 to absorb a fifth persona block.
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: "L5",
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
- async function checkLayer(root, layer, errors) {
166
- for (const path of layer.files) {
167
- const text = await readText(root, path);
168
- if (text == null) continue;
169
- const lines = lineCount(text);
170
- const words = wordCount(text);
171
- if (lines > layer.maxLines) {
172
- errors.push(
173
- `${path} has ${lines} lines (max ${layer.maxLines}, ${layer.id} ${layer.name})`,
174
- );
175
- }
176
- if (words > layer.maxWords) {
177
- errors.push(
178
- `${path} has ${words} words (max ${layer.maxWords}, ${layer.id} ${layer.name})`,
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 checkChecklists(root, sources, errors) {
185
- for (const path of sources) {
186
- const text = await readText(root, path);
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 index = 0;
223
+ let blockIndex = 0;
190
224
  while ((m = CHECKLIST_RE.exec(text))) {
191
- index += 1;
192
- const type = m[1];
225
+ blockIndex += 1;
193
226
  const items = m[2].split(ITEM_SPLIT_RE).slice(1);
194
- if (items.length > L6_MAX_ITEMS) {
195
- errors.push(
196
- `${path} checklist #${index} (${type}) has ${items.length} items (max ${L6_MAX_ITEMS}, L6 checklist)`,
197
- );
198
- }
199
- items.forEach((raw, i) => {
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–L6 caps from COALIGNED.md.
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<string[]>} List of human-readable error messages; empty when the repo is conformant.
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
- for (const layer of layers) await checkLayer(root, layer, errors);
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
- return errors;
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 validateEntry(entry, prefix) {
59
- const errors = [];
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
- function checkHireUniqueness(entry, prefix, allHires, loc) {
81
- const errors = [];
82
- for (const field of ["bigHire", "littleHire"]) {
83
- if (!entry[field] || typeof entry[field] !== "string") continue;
84
- const key = `${field}:${entry[field].toLowerCase()}`;
85
- if (allHires.has(key) && allHires.get(key).goal !== entry.goal) {
86
- errors.push(
87
- `${prefix}: duplicate ${field} "${entry[field]}" (also in ${allHires.get(key).loc})`,
88
- );
89
- }
90
- allHires.set(key, { loc, goal: entry.goal });
91
- }
92
- return errors;
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 validate(packages, dirName, { skipUniqueHires = false } = {}) {
96
- const allHires = new Map();
97
- const errors = [];
150
+ function buildSubjects(packages, catalogDir, catalogName, skipUniqueHires) {
151
+ const packageSubjects = [];
152
+ const entrySubjects = [];
98
153
  for (const { dir, pkg } of packages) {
99
- const jobs = pkg.jobs;
100
- if (!jobs) continue;
101
- if (!Array.isArray(jobs)) {
102
- errors.push(`${dir}/package.json: .jobs must be an array`);
103
- continue;
104
- }
105
- for (let i = 0; i < jobs.length; i++) {
106
- const entry = jobs[i];
107
- const prefix = `${dirName}/${dir}/package.json .jobs[${i}]`;
108
- errors.push(...validateEntry(entry, prefix));
109
- if (!skipUniqueHires) {
110
- errors.push(
111
- ...checkHireUniqueness(entry, prefix, allHires, `${dirName}/${dir}`),
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 errors;
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 errors = validate(packages, catalog.name, {
419
+ const findings = validate(packages, catalog.dir, catalog.name, {
344
420
  skipUniqueHires: catalog.skipUniqueHires ?? false,
345
421
  });
346
- if (errors.length > 0) {
347
- result.errors.push(...errors);
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 errors = validate(packages, "products", { skipUniqueHires: true });
398
- if (errors.length > 0) {
399
- if (result.errors.length === 0) result.errors.push(...errors);
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<{ errors: string[], stale: string[], fixed: string[] }>}
416
- * `errors` are validation failures; `stale` is files whose generated blocks
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 = { errors: [], stale: [], fixed: [] };
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