@forwardimpact/libcoaligned 0.1.6 → 0.1.7

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