@forwardimpact/libcoaligned 0.1.6 → 0.1.8

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
@@ -2,18 +2,15 @@
2
2
 
3
3
  import "@forwardimpact/libpreflight/node22";
4
4
 
5
- import { readFileSync } from "node:fs";
5
+ import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
6
6
  import { createCli } from "@forwardimpact/libcli";
7
7
  import { emitFindingsJson, emitFindingsText } from "@forwardimpact/libutil";
8
8
  import { checkInstructions, checkJtbd } from "../src/index.js";
9
9
 
10
- const { version: VERSION } = JSON.parse(
11
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
12
- );
10
+ const runtime = createDefaultRuntime();
13
11
 
14
12
  const definition = {
15
13
  name: "coaligned",
16
- version: VERSION,
17
14
  description:
18
15
  "Enforce the layered instruction architecture defined in COALIGNED.md (no subcommand: run every check)",
19
16
  commands: [
@@ -46,57 +43,72 @@ const definition = {
46
43
  examples: ["coaligned", "coaligned instructions", "coaligned jtbd --fix"],
47
44
  };
48
45
 
49
- const cli = createCli(definition);
46
+ const cli = createCli(definition, {
47
+ runtime,
48
+ packageJsonUrl: new URL("../package.json", import.meta.url),
49
+ });
50
50
 
51
- function writeFindings(findings, passMessage, jsonOutput, cwd) {
51
+ function writeFindings(findings, passMessage, jsonOutput, cwd, rt) {
52
52
  if (jsonOutput) {
53
- process.stdout.write(emitFindingsJson(findings));
53
+ rt.proc.stdout.write(emitFindingsJson(findings));
54
54
  } else if (findings.length > 0) {
55
- process.stderr.write(emitFindingsText(findings, { cwd, passMessage }));
55
+ rt.proc.stderr.write(emitFindingsText(findings, { cwd, passMessage }));
56
56
  } else {
57
- process.stdout.write(emitFindingsText(findings, { cwd, passMessage }));
57
+ rt.proc.stdout.write(emitFindingsText(findings, { cwd, passMessage }));
58
58
  }
59
59
  }
60
60
 
61
- async function runInstructions(root, jsonOutput) {
62
- const findings = await checkInstructions({ root });
63
- writeFindings(findings, "coaligned instructions passed", jsonOutput, root);
61
+ async function runInstructions(root, jsonOutput, rt) {
62
+ const findings = await checkInstructions({ root, runtime: rt });
63
+ writeFindings(
64
+ findings,
65
+ "coaligned instructions passed",
66
+ jsonOutput,
67
+ root,
68
+ rt,
69
+ );
64
70
  return findings.length > 0 ? 1 : 0;
65
71
  }
66
72
 
67
- async function runJtbd(root, fix, jsonOutput) {
68
- const { findings, stale, fixed } = await checkJtbd({ root, fix });
69
- writeFindings(findings, "coaligned jtbd passed", jsonOutput, root);
70
- for (const f of fixed) process.stdout.write(`Regenerated ${f}.\n`);
73
+ async function runJtbd(root, fix, jsonOutput, rt) {
74
+ const { findings, stale, fixed } = await checkJtbd({
75
+ root,
76
+ fix,
77
+ runtime: rt,
78
+ });
79
+ writeFindings(findings, "coaligned jtbd passed", jsonOutput, root, rt);
80
+ for (const f of fixed) rt.proc.stdout.write(`Regenerated ${f}.\n`);
71
81
  if (stale.length > 0 && !jsonOutput) {
72
- process.stderr.write(
82
+ rt.proc.stderr.write(
73
83
  `\n${stale.length} file${stale.length === 1 ? "" : "s"} out of date — run \`coaligned jtbd --fix\` to regenerate:\n`,
74
84
  );
75
- for (const s of stale) process.stderr.write(` - ${s}\n`);
85
+ for (const s of stale) rt.proc.stderr.write(` - ${s}\n`);
76
86
  }
77
87
  return findings.length > 0 || stale.length > 0 ? 1 : 0;
78
88
  }
79
89
 
80
90
  async function instructionsHandler(ctx) {
81
- return runInstructions(ctx.data.root, !!ctx.options.json);
91
+ const rt = ctx.deps.runtime;
92
+ return runInstructions(ctx.data.root, !!ctx.options.json, rt);
82
93
  }
83
94
 
84
95
  async function jtbdHandler(ctx) {
85
- return runJtbd(ctx.data.root, !!ctx.options.fix, !!ctx.options.json);
96
+ const rt = ctx.deps.runtime;
97
+ return runJtbd(ctx.data.root, !!ctx.options.fix, !!ctx.options.json, rt);
86
98
  }
87
99
 
88
100
  async function main() {
89
- const parsed = cli.parse(process.argv.slice(2));
90
- if (!parsed) return 0;
101
+ const parsed = cli.parse(runtime.proc.argv.slice(2));
102
+ if (!parsed) return runtime.proc.exit(0);
91
103
 
92
- const root = process.cwd();
104
+ const root = runtime.proc.cwd();
93
105
  const jsonOutput = !!parsed.values.json;
94
106
 
95
107
  // No subcommand → run every check; --fix stays jtbd-only and must be opted
96
108
  // into explicitly via `coaligned jtbd --fix`.
97
109
  if (parsed.positionals.length === 0) {
98
- const a = await runInstructions(root, jsonOutput);
99
- const b = await runJtbd(root, false, jsonOutput);
110
+ const a = await runInstructions(root, jsonOutput, runtime);
111
+ const b = await runJtbd(root, false, jsonOutput, runtime);
100
112
  return a || b;
101
113
  }
102
114
 
@@ -106,12 +118,12 @@ async function main() {
106
118
  return 2;
107
119
  }
108
120
 
109
- return await cli.dispatch(parsed, { data: { root } });
121
+ return await cli.dispatch(parsed, { data: { root }, deps: { runtime } });
110
122
  }
111
123
 
112
124
  main()
113
- .then((code) => process.exit(code ?? 0))
125
+ .then((code) => runtime.proc.exit(code ?? 0))
114
126
  .catch((err) => {
115
127
  cli.error(err.message);
116
- process.exit(1);
128
+ runtime.proc.exit(1);
117
129
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libcoaligned",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Co-Aligned architecture checks — enforce instruction-layer length caps and JTBD invariants across the repo.",
5
5
  "keywords": [
6
6
  "coaligned",
@@ -50,6 +50,9 @@
50
50
  "@forwardimpact/libutil": "*",
51
51
  "prettier": "^3.0.0"
52
52
  },
53
+ "devDependencies": {
54
+ "@forwardimpact/libmock": "^0.1.0"
55
+ },
53
56
  "engines": {
54
57
  "bun": ">=1.2.0",
55
58
  "node": ">=22.0.0"
@@ -1,4 +1,3 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
1
  import { resolve } from "node:path";
3
2
  import { runRules } from "@forwardimpact/libutil";
4
3
 
@@ -24,10 +23,10 @@ const ITEM_SPLIT_RE = /^\s*-\s*\[[ xX]\]\s*/m;
24
23
  const lineCount = (text) => (text.match(/\n/g) || []).length;
25
24
  const wordCount = (text) => (text.match(/\S+/g) || []).length;
26
25
 
27
- async function walk(root, dir, visit) {
26
+ async function walk(root, dir, visit, fs) {
28
27
  let entries;
29
28
  try {
30
- entries = await readdir(resolve(root, dir), { withFileTypes: true });
29
+ entries = await fs.readdir(resolve(root, dir), { withFileTypes: true });
31
30
  } catch {
32
31
  return;
33
32
  }
@@ -35,88 +34,103 @@ async function walk(root, dir, visit) {
35
34
  if (SKIP_DIRS.has(e.name)) continue;
36
35
  const path = dir === "." ? e.name : `${dir}/${e.name}`;
37
36
  await visit(e, path);
38
- if (e.isDirectory()) await walk(root, path, visit);
37
+ if (e.isDirectory()) await walk(root, path, visit, fs);
39
38
  }
40
39
  }
41
40
 
42
- async function listFiles(root, dir, match) {
41
+ async function listFiles(root, dir, match, fs) {
43
42
  try {
44
- const entries = await readdir(resolve(root, dir), { withFileTypes: true });
43
+ const entries = await fs.readdir(resolve(root, dir), {
44
+ withFileTypes: true,
45
+ });
45
46
  return entries.filter(match).map((e) => `${dir}/${e.name}`);
46
47
  } catch {
47
48
  return [];
48
49
  }
49
50
  }
50
51
 
51
- async function readText(root, path) {
52
+ async function readText(root, path, fs) {
52
53
  try {
53
- return await readFile(resolve(root, path), "utf8");
54
+ return await fs.readFile(resolve(root, path), "utf8");
54
55
  } catch {
55
56
  return null;
56
57
  }
57
58
  }
58
59
 
59
- async function findByName(root, name, kind) {
60
+ async function findByName(root, name, kind, fs) {
60
61
  const out = [];
61
- await walk(root, ".", (e, path) => {
62
- const isMatch = kind === "file" ? e.isFile() : e.isDirectory();
63
- if (isMatch && e.name === name) out.push(path);
64
- });
62
+ await walk(
63
+ root,
64
+ ".",
65
+ (e, path) => {
66
+ const isMatch = kind === "file" ? e.isFile() : e.isDirectory();
67
+ if (isMatch && e.name === name) out.push(path);
68
+ },
69
+ fs,
70
+ );
65
71
  return out;
66
72
  }
67
73
 
68
- async function findAgentProfiles(root, claudeDirs) {
74
+ async function findAgentProfiles(root, claudeDirs, fs) {
69
75
  const out = [];
70
76
  for (const d of claudeDirs) {
71
77
  const files = await listFiles(
72
78
  root,
73
79
  `${d}/agents`,
74
80
  (e) => e.isFile() && e.name.endsWith(".md"),
81
+ fs,
75
82
  );
76
83
  out.push(...files);
77
84
  }
78
85
  return out;
79
86
  }
80
87
 
81
- async function findAgentReferences(root, claudeDirs) {
88
+ async function findAgentReferences(root, claudeDirs, fs) {
82
89
  const out = [];
83
90
  for (const d of claudeDirs) {
84
91
  const files = await listFiles(
85
92
  root,
86
93
  `${d}/agents/references`,
87
94
  (e) => e.isFile() && e.name.endsWith(".md"),
95
+ fs,
88
96
  );
89
97
  out.push(...files);
90
98
  }
91
99
  return out;
92
100
  }
93
101
 
94
- async function findSkillDirs(root, claudeDirs) {
102
+ async function findSkillDirs(root, claudeDirs, fs) {
95
103
  const out = [];
96
104
  for (const d of claudeDirs) {
97
- const dirs = await listFiles(root, `${d}/skills`, (e) => e.isDirectory());
105
+ const dirs = await listFiles(
106
+ root,
107
+ `${d}/skills`,
108
+ (e) => e.isDirectory(),
109
+ fs,
110
+ );
98
111
  out.push(...dirs);
99
112
  }
100
113
  return out;
101
114
  }
102
115
 
103
- async function findSkillReferences(root, skillDirs) {
116
+ async function findSkillReferences(root, skillDirs, fs) {
104
117
  const out = [];
105
118
  for (const d of skillDirs) {
106
119
  const files = await listFiles(
107
120
  root,
108
121
  `${d}/references`,
109
122
  (e) => e.isFile() && e.name.endsWith(".md"),
123
+ fs,
110
124
  );
111
125
  out.push(...files);
112
126
  }
113
127
  return out;
114
128
  }
115
129
 
116
- async function buildLayers(root) {
117
- const claudeDirs = await findByName(root, ".claude", "dir");
118
- const skillDirs = await findSkillDirs(root, claudeDirs);
119
- const allClaude = await findByName(root, "CLAUDE.md", "file");
130
+ async function buildLayers(root, fs) {
131
+ const claudeDirs = await findByName(root, ".claude", "dir", fs);
132
+ const skillDirs = await findSkillDirs(root, claudeDirs, fs);
133
+ const allClaude = await findByName(root, "CLAUDE.md", "file", fs);
120
134
  const rootClaude = allClaude.filter((p) => p === "CLAUDE.md");
121
135
  const subdirClaude = allClaude.filter((p) => p !== "CLAUDE.md");
122
136
  return {
@@ -156,14 +170,14 @@ async function buildLayers(root) {
156
170
  name: "agent profile",
157
171
  maxLines: 72,
158
172
  maxWords: 448,
159
- files: await findAgentProfiles(root, claudeDirs),
173
+ files: await findAgentProfiles(root, claudeDirs, fs),
160
174
  },
161
175
  {
162
176
  id: "L4",
163
177
  name: "agent reference",
164
178
  maxLines: 192,
165
179
  maxWords: 1280,
166
- files: await findAgentReferences(root, claudeDirs),
180
+ files: await findAgentReferences(root, claudeDirs, fs),
167
181
  },
168
182
  {
169
183
  id: "L5",
@@ -177,7 +191,7 @@ async function buildLayers(root) {
177
191
  name: "skill reference",
178
192
  maxLines: 128,
179
193
  maxWords: 768,
180
- files: await findSkillReferences(root, skillDirs),
194
+ files: await findSkillReferences(root, skillDirs, fs),
181
195
  },
182
196
  ],
183
197
  };
@@ -193,11 +207,11 @@ function offsetToLine(text, offset) {
193
207
 
194
208
  // -- Subject builders ----------------------------------------------------
195
209
 
196
- async function buildFileSubjects(root, layers) {
210
+ async function buildFileSubjects(root, layers, fs) {
197
211
  const subjects = [];
198
212
  for (const layer of layers) {
199
213
  for (const relPath of layer.files) {
200
- const text = await readText(root, relPath);
214
+ const text = await readText(root, relPath, fs);
201
215
  if (text == null) continue;
202
216
  subjects.push({
203
217
  path: resolve(root, relPath),
@@ -212,10 +226,10 @@ async function buildFileSubjects(root, layers) {
212
226
  return subjects;
213
227
  }
214
228
 
215
- async function buildChecklistSubjects(root, sources) {
229
+ async function buildChecklistSubjects(root, sources, fs) {
216
230
  const subjects = [];
217
231
  for (const relPath of sources) {
218
- const text = await readText(root, relPath);
232
+ const text = await readText(root, relPath, fs);
219
233
  if (text == null) continue;
220
234
  const absPath = resolve(root, relPath);
221
235
  CHECKLIST_RE.lastIndex = 0;
@@ -301,18 +315,21 @@ export const INSTRUCTION_RULES = [
301
315
  * Walk the repo rooted at `root`, applying the L1–L7 caps from COALIGNED.md.
302
316
  * Each layer is gated by a line cap AND a word cap; either breach fails.
303
317
  *
304
- * @param {{ root: string }} options
318
+ * @param {{ root: string, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
305
319
  * @returns {Promise<Finding[]>} Structured findings; empty when conformant.
306
320
  * Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
307
321
  * with `emitFindingsText` / `emitFindingsJson` from libutil.
308
322
  */
309
- export async function checkInstructions({ root }) {
310
- const { layers, skillDirs } = await buildLayers(root);
311
- const fileSubjects = await buildFileSubjects(root, layers);
312
- const checklistSubjects = await buildChecklistSubjects(root, [
313
- "CONTRIBUTING.md",
314
- ...skillDirs.map((d) => `${d}/SKILL.md`),
315
- ]);
323
+ export async function checkInstructions({ root, runtime }) {
324
+ if (!runtime) throw new Error("runtime is required");
325
+ const { fs } = runtime;
326
+ const { layers, skillDirs } = await buildLayers(root, fs);
327
+ const fileSubjects = await buildFileSubjects(root, layers, fs);
328
+ const checklistSubjects = await buildChecklistSubjects(
329
+ root,
330
+ ["CONTRIBUTING.md", ...skillDirs.map((d) => `${d}/SKILL.md`)],
331
+ fs,
332
+ );
316
333
 
317
334
  const ctx = {
318
335
  subjects: {
package/src/jtbd.js CHANGED
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import * as prettier from "prettier";
4
3
  import { runRules } from "@forwardimpact/libutil";
@@ -39,15 +38,15 @@ function catalogs(root) {
39
38
  ];
40
39
  }
41
40
 
42
- function loadPackages(dir, filter) {
43
- if (!existsSync(dir)) return [];
41
+ function loadPackages(dir, filter, fsSync) {
42
+ if (!fsSync.existsSync(dir)) return [];
44
43
  const out = [];
45
- for (const name of readdirSync(dir)) {
44
+ for (const name of fsSync.readdirSync(dir)) {
46
45
  if (!filter(name)) continue;
47
46
  const pkgPath = join(dir, name, "package.json");
48
47
  let pkg;
49
48
  try {
50
- pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
49
+ pkg = JSON.parse(fsSync.readFileSync(pkgPath, "utf8"));
51
50
  } catch {
52
51
  continue;
53
52
  }
@@ -404,18 +403,18 @@ async function buildDescription(content, description, formatMarkdown) {
404
403
  );
405
404
  }
406
405
 
407
- function commitUpdate(filePath, label, original, updated, fix, result) {
406
+ function commitUpdate(filePath, label, original, updated, fix, result, fsSync) {
408
407
  if (updated === null || updated === original) return;
409
408
  if (!fix) {
410
409
  result.stale.push(label);
411
410
  return;
412
411
  }
413
- writeFileSync(filePath, updated);
412
+ fsSync.writeFileSync(filePath, updated);
414
413
  result.fixed.push(label);
415
414
  }
416
415
 
417
- async function processCatalog(catalog, fix, formatMarkdown, result) {
418
- const packages = loadPackages(catalog.dir, catalog.filter);
416
+ async function processCatalog(catalog, fix, formatMarkdown, result, fsSync) {
417
+ const packages = loadPackages(catalog.dir, catalog.filter, fsSync);
419
418
  const findings = validate(packages, catalog.dir, catalog.name, {
420
419
  skipUniqueHires: catalog.skipUniqueHires ?? false,
421
420
  });
@@ -424,8 +423,8 @@ async function processCatalog(catalog, fix, formatMarkdown, result) {
424
423
  return;
425
424
  }
426
425
 
427
- if (existsSync(catalog.readme)) {
428
- const original = readFileSync(catalog.readme, "utf8");
426
+ if (fsSync.existsSync(catalog.readme)) {
427
+ const original = fsSync.readFileSync(catalog.readme, "utf8");
429
428
  let content = original;
430
429
  content = await buildCatalog(
431
430
  content,
@@ -441,14 +440,15 @@ async function processCatalog(catalog, fix, formatMarkdown, result) {
441
440
  content,
442
441
  fix,
443
442
  result,
443
+ fsSync,
444
444
  );
445
445
  }
446
446
 
447
447
  for (const { dir, pkg } of packages) {
448
448
  if (!pkg.description) continue;
449
449
  const pkgReadme = join(catalog.dir, dir, "README.md");
450
- if (!existsSync(pkgReadme)) continue;
451
- const original = readFileSync(pkgReadme, "utf8");
450
+ if (!fsSync.existsSync(pkgReadme)) continue;
451
+ const original = fsSync.readFileSync(pkgReadme, "utf8");
452
452
  const updated = await buildDescription(
453
453
  original,
454
454
  pkg.description,
@@ -461,15 +461,20 @@ async function processCatalog(catalog, fix, formatMarkdown, result) {
461
461
  updated,
462
462
  fix,
463
463
  result,
464
+ fsSync,
464
465
  );
465
466
  }
466
467
  }
467
468
 
468
- async function processJtbdMd(root, fix, formatMarkdown, result) {
469
+ async function processJtbdMd(root, fix, formatMarkdown, result, fsSync) {
469
470
  const jtbdPath = join(root, "JTBD.md");
470
- if (!existsSync(jtbdPath)) return;
471
+ if (!fsSync.existsSync(jtbdPath)) return;
471
472
  const productsCatalog = catalogs(root).find((c) => c.name === "products");
472
- const packages = loadPackages(productsCatalog.dir, productsCatalog.filter);
473
+ const packages = loadPackages(
474
+ productsCatalog.dir,
475
+ productsCatalog.filter,
476
+ fsSync,
477
+ );
473
478
  const findings = validate(packages, productsCatalog.dir, "products", {
474
479
  skipUniqueHires: true,
475
480
  });
@@ -477,11 +482,11 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
477
482
  if (result.findings.length === 0) result.findings.push(...findings);
478
483
  return;
479
484
  }
480
- const original = readFileSync(jtbdPath, "utf8");
485
+ const original = fsSync.readFileSync(jtbdPath, "utf8");
481
486
  const updated = await buildJobs(original, packages, formatMarkdown, {
482
487
  capitalize: true,
483
488
  });
484
- commitUpdate(jtbdPath, "JTBD.md", original, updated, fix, result);
489
+ commitUpdate(jtbdPath, "JTBD.md", original, updated, fix, result, fsSync);
485
490
  }
486
491
 
487
492
  /**
@@ -489,22 +494,24 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
489
494
  * libraries/, and (when `fix` is true) regenerate the marker-delimited catalog,
490
495
  * jobs, and description blocks in the corresponding README.md and JTBD.md.
491
496
  *
492
- * @param {{ root: string, fix?: boolean }} options
497
+ * @param {{ root: string, fix?: boolean, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
493
498
  * @returns {Promise<{ findings: Finding[], stale: string[], fixed: string[] }>}
494
499
  * `findings` are validation failures (structured for `emitFindingsText` /
495
500
  * `emitFindingsJson` from libutil); `stale` is files whose generated blocks
496
501
  * are out of date (only populated when `fix` is false); `fixed` is files
497
502
  * that were rewritten in place.
498
503
  */
499
- export async function checkJtbd({ root, fix = false }) {
504
+ export async function checkJtbd({ root, fix = false, runtime }) {
505
+ if (!runtime) throw new Error("runtime is required");
506
+ const { fsSync } = runtime;
500
507
  const result = { findings: [], stale: [], fixed: [] };
501
508
  const prettierConfig = await prettier.resolveConfig(join(root, "JTBD.md"));
502
509
  const formatMarkdown = makeFormatter(prettierConfig);
503
510
 
504
511
  for (const catalog of catalogs(root)) {
505
- await processCatalog(catalog, fix, formatMarkdown, result);
512
+ await processCatalog(catalog, fix, formatMarkdown, result, fsSync);
506
513
  }
507
- await processJtbdMd(root, fix, formatMarkdown, result);
514
+ await processJtbdMd(root, fix, formatMarkdown, result, fsSync);
508
515
 
509
516
  return result;
510
517
  }