@forwardimpact/libcoaligned 0.1.4 → 0.1.5

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/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.5",
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",
@@ -133,7 +134,7 @@ async function buildLayers(root) {
133
134
  id: "L2",
134
135
  name: "JTBD.md",
135
136
  maxLines: 320,
136
- // Bumped from 1408 in spec 1010 to absorb a fifth persona block.
137
+ // Larger than the L2 default to absorb a fifth persona block.
137
138
  maxWords: 1664,
138
139
  files: ["JTBD.md"],
139
140
  },
@@ -162,70 +163,143 @@ async function buildLayers(root) {
162
163
  };
163
164
  }
164
165
 
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
- );
166
+ function offsetToLine(text, offset) {
167
+ let line = 1;
168
+ for (let i = 0; i < offset && i < text.length; i++) {
169
+ if (text.charCodeAt(i) === 10) line++;
170
+ }
171
+ return line;
172
+ }
173
+
174
+ // -- Subject builders ----------------------------------------------------
175
+
176
+ async function buildFileSubjects(root, layers) {
177
+ const subjects = [];
178
+ for (const layer of layers) {
179
+ for (const relPath of layer.files) {
180
+ const text = await readText(root, relPath);
181
+ if (text == null) continue;
182
+ subjects.push({
183
+ path: resolve(root, relPath),
184
+ layer: { id: layer.id, name: layer.name },
185
+ lines: lineCount(text),
186
+ words: wordCount(text),
187
+ maxLines: layer.maxLines,
188
+ maxWords: layer.maxWords,
189
+ });
180
190
  }
181
191
  }
192
+ return subjects;
182
193
  }
183
194
 
184
- async function checkChecklists(root, sources, errors) {
185
- for (const path of sources) {
186
- const text = await readText(root, path);
195
+ async function buildChecklistSubjects(root, sources) {
196
+ const subjects = [];
197
+ for (const relPath of sources) {
198
+ const text = await readText(root, relPath);
187
199
  if (text == null) continue;
200
+ const absPath = resolve(root, relPath);
201
+ CHECKLIST_RE.lastIndex = 0;
188
202
  let m;
189
- let index = 0;
203
+ let blockIndex = 0;
190
204
  while ((m = CHECKLIST_RE.exec(text))) {
191
- index += 1;
192
- const type = m[1];
205
+ blockIndex += 1;
193
206
  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
- }
207
+ subjects.push({
208
+ path: absPath,
209
+ lineNo: offsetToLine(text, m.index),
210
+ type: m[1],
211
+ blockIndex,
212
+ items: items.map((raw) => ({ words: wordCount(raw.trim()) })),
206
213
  });
207
214
  }
208
215
  }
216
+ return subjects;
209
217
  }
210
218
 
219
+ const HINT_LAYER_BUDGET =
220
+ "trim prose to fit the layer cap — see COALIGNED.md for the layered-instruction model";
221
+
222
+ // -- Rule catalogue ------------------------------------------------------
223
+
224
+ export const INSTRUCTION_RULES = [
225
+ {
226
+ id: "instructions.line-budget",
227
+ scope: "instruction-file",
228
+ severity: "fail",
229
+ check: (s) =>
230
+ s.lines > s.maxLines ? { value: s.lines, max: s.maxLines } : null,
231
+ message: (s, r) => `${r.value} lines (max ${r.max}, ${s.layer.name})`,
232
+ hint: HINT_LAYER_BUDGET,
233
+ },
234
+ {
235
+ id: "instructions.word-budget",
236
+ scope: "instruction-file",
237
+ severity: "fail",
238
+ check: (s) =>
239
+ s.words > s.maxWords ? { value: s.words, max: s.maxWords } : null,
240
+ message: (s, r) => `${r.value} words (max ${r.max}, ${s.layer.name})`,
241
+ hint: HINT_LAYER_BUDGET,
242
+ },
243
+ {
244
+ id: "L6.too-many-items",
245
+ scope: "checklist-block",
246
+ severity: "fail",
247
+ check: (s) =>
248
+ s.items.length > L6_MAX_ITEMS
249
+ ? { count: s.items.length, max: L6_MAX_ITEMS }
250
+ : null,
251
+ message: (s, r) =>
252
+ `checklist #${s.blockIndex} (${s.type}) has ${r.count} items (max ${r.max})`,
253
+ hint: "split the checklist into multiple sections, or remove items not load-bearing for the goal",
254
+ },
255
+ {
256
+ id: "L6.item-too-many-words",
257
+ scope: "checklist-block",
258
+ severity: "fail",
259
+ check: (s) => {
260
+ const offenders = [];
261
+ s.items.forEach((item, i) => {
262
+ if (item.words > L6_MAX_WORDS_PER_ITEM) {
263
+ offenders.push({
264
+ itemIndex: i + 1,
265
+ words: item.words,
266
+ max: L6_MAX_WORDS_PER_ITEM,
267
+ });
268
+ }
269
+ });
270
+ return offenders.length === 0 ? null : offenders;
271
+ },
272
+ message: (s, r) =>
273
+ `checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
274
+ hint: "rewrite the item more concisely — checklist items are pointers, not explanations",
275
+ },
276
+ ];
277
+
278
+ // -- Public entry --------------------------------------------------------
279
+
211
280
  /**
212
281
  * Walk the repo rooted at `root`, applying the L1–L6 caps from COALIGNED.md.
213
282
  * Each layer is gated by a line cap AND a word cap; either breach fails.
214
283
  *
215
284
  * @param {{ root: string }} options
216
- * @returns {Promise<string[]>} List of human-readable error messages; empty when the repo is conformant.
285
+ * @returns {Promise<Finding[]>} Structured findings; empty when conformant.
286
+ * Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
287
+ * with `emitFindingsText` / `emitFindingsJson` from libutil.
217
288
  */
218
289
  export async function checkInstructions({ root }) {
219
- const errors = [];
220
290
  const { layers, skillDirs } = await buildLayers(root);
221
-
222
- for (const layer of layers) await checkLayer(root, layer, errors);
223
-
224
- const checklistSources = [
291
+ const fileSubjects = await buildFileSubjects(root, layers);
292
+ const checklistSubjects = await buildChecklistSubjects(root, [
225
293
  "CONTRIBUTING.md",
226
294
  ...skillDirs.map((d) => `${d}/SKILL.md`),
227
- ];
228
- await checkChecklists(root, checklistSources, errors);
295
+ ]);
229
296
 
230
- return errors;
297
+ const ctx = {
298
+ subjects: {
299
+ "instruction-file": fileSubjects,
300
+ "checklist-block": checklistSubjects,
301
+ },
302
+ };
303
+ const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
304
+ return runRules(INSTRUCTION_RULES, ctx, { resolveScope });
231
305
  }
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