@forwardimpact/libcoaligned 0.1.5 → 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/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
@@ -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.5",
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([
@@ -14,8 +14,8 @@ const SKIP_DIRS = new Set([
14
14
  "worktrees",
15
15
  ]);
16
16
 
17
- const L6_MAX_ITEMS = 9;
18
- const L6_MAX_WORDS_PER_ITEM = 32;
17
+ const L7_MAX_ITEMS = 9;
18
+ const L7_MAX_WORDS_PER_ITEM = 32;
19
19
 
20
20
  const CHECKLIST_RE =
21
21
  /<(read_do_checklist|do_confirm_checklist)\b[^>]*>([\s\S]*?)<\/\1>/g;
@@ -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,75 +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,
83
+ );
84
+ out.push(...files);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ async function findAgentReferences(root, claudeDirs, fs) {
90
+ const out = [];
91
+ for (const d of claudeDirs) {
92
+ const files = await listFiles(
93
+ root,
94
+ `${d}/agents/references`,
95
+ (e) => e.isFile() && e.name.endsWith(".md"),
96
+ fs,
75
97
  );
76
98
  out.push(...files);
77
99
  }
78
100
  return out;
79
101
  }
80
102
 
81
- async function findSkillDirs(root, claudeDirs) {
103
+ async function findSkillDirs(root, claudeDirs, fs) {
82
104
  const out = [];
83
105
  for (const d of claudeDirs) {
84
- 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
+ );
85
112
  out.push(...dirs);
86
113
  }
87
114
  return out;
88
115
  }
89
116
 
90
- async function findSkillReferences(root, skillDirs) {
117
+ async function findSkillReferences(root, skillDirs, fs) {
91
118
  const out = [];
92
119
  for (const d of skillDirs) {
93
120
  const files = await listFiles(
94
121
  root,
95
122
  `${d}/references`,
96
123
  (e) => e.isFile() && e.name.endsWith(".md"),
124
+ fs,
97
125
  );
98
126
  out.push(...files);
99
127
  }
100
128
  return out;
101
129
  }
102
130
 
103
- async function buildLayers(root) {
104
- const claudeDirs = await findByName(root, ".claude", "dir");
105
- const skillDirs = await findSkillDirs(root, claudeDirs);
106
- 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);
107
135
  const rootClaude = allClaude.filter((p) => p === "CLAUDE.md");
108
136
  const subdirClaude = allClaude.filter((p) => p !== "CLAUDE.md");
109
137
  return {
@@ -143,21 +171,28 @@ async function buildLayers(root) {
143
171
  name: "agent profile",
144
172
  maxLines: 72,
145
173
  maxWords: 448,
146
- files: await findAgentProfiles(root, claudeDirs),
174
+ files: await findAgentProfiles(root, claudeDirs, fs),
147
175
  },
148
176
  {
149
177
  id: "L4",
178
+ name: "agent reference",
179
+ maxLines: 192,
180
+ maxWords: 1280,
181
+ files: await findAgentReferences(root, claudeDirs, fs),
182
+ },
183
+ {
184
+ id: "L5",
150
185
  name: "skill procedure",
151
186
  maxLines: 192,
152
187
  maxWords: 1280,
153
188
  files: skillDirs.map((d) => `${d}/SKILL.md`),
154
189
  },
155
190
  {
156
- id: "L5",
191
+ id: "L6",
157
192
  name: "skill reference",
158
193
  maxLines: 128,
159
194
  maxWords: 768,
160
- files: await findSkillReferences(root, skillDirs),
195
+ files: await findSkillReferences(root, skillDirs, fs),
161
196
  },
162
197
  ],
163
198
  };
@@ -173,11 +208,11 @@ function offsetToLine(text, offset) {
173
208
 
174
209
  // -- Subject builders ----------------------------------------------------
175
210
 
176
- async function buildFileSubjects(root, layers) {
211
+ async function buildFileSubjects(root, layers, fs) {
177
212
  const subjects = [];
178
213
  for (const layer of layers) {
179
214
  for (const relPath of layer.files) {
180
- const text = await readText(root, relPath);
215
+ const text = await readText(root, relPath, fs);
181
216
  if (text == null) continue;
182
217
  subjects.push({
183
218
  path: resolve(root, relPath),
@@ -192,10 +227,10 @@ async function buildFileSubjects(root, layers) {
192
227
  return subjects;
193
228
  }
194
229
 
195
- async function buildChecklistSubjects(root, sources) {
230
+ async function buildChecklistSubjects(root, sources, fs) {
196
231
  const subjects = [];
197
232
  for (const relPath of sources) {
198
- const text = await readText(root, relPath);
233
+ const text = await readText(root, relPath, fs);
199
234
  if (text == null) continue;
200
235
  const absPath = resolve(root, relPath);
201
236
  CHECKLIST_RE.lastIndex = 0;
@@ -241,29 +276,29 @@ export const INSTRUCTION_RULES = [
241
276
  hint: HINT_LAYER_BUDGET,
242
277
  },
243
278
  {
244
- id: "L6.too-many-items",
279
+ id: "L7.too-many-items",
245
280
  scope: "checklist-block",
246
281
  severity: "fail",
247
282
  check: (s) =>
248
- s.items.length > L6_MAX_ITEMS
249
- ? { count: s.items.length, max: L6_MAX_ITEMS }
283
+ s.items.length > L7_MAX_ITEMS
284
+ ? { count: s.items.length, max: L7_MAX_ITEMS }
250
285
  : null,
251
286
  message: (s, r) =>
252
287
  `checklist #${s.blockIndex} (${s.type}) has ${r.count} items (max ${r.max})`,
253
288
  hint: "split the checklist into multiple sections, or remove items not load-bearing for the goal",
254
289
  },
255
290
  {
256
- id: "L6.item-too-many-words",
291
+ id: "L7.item-too-many-words",
257
292
  scope: "checklist-block",
258
293
  severity: "fail",
259
294
  check: (s) => {
260
295
  const offenders = [];
261
296
  s.items.forEach((item, i) => {
262
- if (item.words > L6_MAX_WORDS_PER_ITEM) {
297
+ if (item.words > L7_MAX_WORDS_PER_ITEM) {
263
298
  offenders.push({
264
299
  itemIndex: i + 1,
265
300
  words: item.words,
266
- max: L6_MAX_WORDS_PER_ITEM,
301
+ max: L7_MAX_WORDS_PER_ITEM,
267
302
  });
268
303
  }
269
304
  });
@@ -278,21 +313,23 @@ export const INSTRUCTION_RULES = [
278
313
  // -- Public entry --------------------------------------------------------
279
314
 
280
315
  /**
281
- * Walk the repo rooted at `root`, applying the L1–L6 caps from COALIGNED.md.
316
+ * Walk the repo rooted at `root`, applying the L1–L7 caps from COALIGNED.md.
282
317
  * Each layer is gated by a line cap AND a word cap; either breach fails.
283
318
  *
284
- * @param {{ root: string }} options
319
+ * @param {{ root: string, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
285
320
  * @returns {Promise<Finding[]>} Structured findings; empty when conformant.
286
321
  * Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
287
322
  * with `emitFindingsText` / `emitFindingsJson` from libutil.
288
323
  */
289
- export async function checkInstructions({ root }) {
290
- const { layers, skillDirs } = await buildLayers(root);
291
- const fileSubjects = await buildFileSubjects(root, layers);
292
- const checklistSubjects = await buildChecklistSubjects(root, [
293
- "CONTRIBUTING.md",
294
- ...skillDirs.map((d) => `${d}/SKILL.md`),
295
- ]);
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
+ );
296
333
 
297
334
  const ctx = {
298
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
  }