@forwardimpact/libinvariant 0.2.0 → 0.2.2

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
@@ -10,9 +10,9 @@ directory.
10
10
 
11
11
  ## Getting Started
12
12
 
13
- libinvariant is an import-only library: it ships no CLI. To run the checks,
14
- hire the Jidoka product — `npx @forwardimpact/jidoka` or the installed
15
- `jidoka` binary — which wires these handlers to a command surface.
13
+ libinvariant is an import-only library. It ships no CLI. To run the checks,
14
+ hire the Jidoka product. Use `npx @forwardimpact/jidoka` or the installed
15
+ `jidoka` binary. The product wires these handlers to a command surface.
16
16
 
17
17
  ```js
18
18
  import {
@@ -30,26 +30,26 @@ const ruleFindings = await checkInvariants({
30
30
  });
31
31
  ```
32
32
 
33
- The `checkInstructions` and `checkJtbd` handlers implement the contract
34
- described in
35
- [JIDOKA.md](https://github.com/forwardimpact/monorepo/blob/main/JIDOKA.md):
33
+ The `checkInstructions` and `checkJtbd` handlers implement the contract that
34
+ [JIDOKA.md](https://github.com/forwardimpact/monorepo/blob/main/JIDOKA.md)
35
+ describes:
36
36
 
37
- - `checkInstructions` — every layer (L1 CLAUDE.md, L2 CONTRIBUTING.md /
38
- JTBD.md, L3 agent profile, L4 agent reference, L5 SKILL.md, L6 skill
39
- reference, L7 checklist block) is gated by a line cap **and** a word cap.
40
- Either breach fails.
41
- - `checkJtbd` — each `package.json .jobs` entry is validated against the JTBD
42
- schema; with `fix`, marker-delimited blocks in `<dir>/README.md`,
43
- `<dir>/<pkg>/README.md`, and root `JTBD.md` are regenerated.
37
+ - `checkInstructions` — a line cap **and** a word cap gate every layer (L1
38
+ CLAUDE.md, L2 CONTRIBUTING.md / JTBD.md, L3 agent profile, L4 agent
39
+ reference, L5 SKILL.md, L6 skill reference, L7 checklist block). Either
40
+ breach fails.
41
+ - `checkJtbd` — the handler validates each `package.json .jobs` entry against
42
+ the JTBD schema. With `fix`, it regenerates the marker-delimited blocks in
43
+ `<dir>/README.md`, `<dir>/<pkg>/README.md`, and root `JTBD.md`.
44
44
 
45
45
  ## Invariants
46
46
 
47
47
  `checkInvariants` is a generic host for a repository's own invariant checks.
48
- It loads every `*.rules.mjs` module under the caller-supplied `rulesDir` and
48
+ It loads every `*.rules.mjs` module under the caller-supplied `rulesDir`. It
49
49
  runs each module's declarative rule catalogue through the shared rules
50
- engine. The library carries no discovery default — the calling product or
51
- script names the directory (the Jidoka CLI supplies `.jidoka/invariants`).
52
- The policies stay in the repository; the library ships only the engine.
50
+ engine. The library carries no discovery default. The product or script that
51
+ calls it names the directory (the Jidoka CLI supplies `.jidoka/invariants`).
52
+ The policies stay in the repository. The library ships only the engine.
53
53
 
54
54
  A rule module's default export is:
55
55
 
@@ -79,15 +79,15 @@ export default {
79
79
  ### The build kit
80
80
 
81
81
  The engine binds a kit per run to the repo `root`, the module's own `dir`
82
- (for co-located config), and the `runtime` bag (fs and ripgrep route through
83
- it, so the engine carries no ambient dependencies). The module declares only
84
- policy; the kit owns the mechanism:
82
+ (for co-located config), and the `runtime` bag. fs and ripgrep route through
83
+ the bag, so the engine carries no ambient dependencies. The module declares
84
+ only policy. The kit owns the mechanism:
85
85
 
86
86
  - `scan({ dirs, match, skip?, under?, read? })` — collect files as
87
- `{ path, rel, text? }`; `under` restricts to the per-package `src`/`test`
87
+ `{ path, rel, text? }`. `under` restricts to the per-package `src`/`test`
88
88
  shape.
89
89
  - `scanAst({ dirs, match, extract, locations?, … })` — read + parse each file
90
- and merge `extract(ast)`; a parse failure becomes `{ path, rel, parseError }`.
90
+ and merge `extract(ast)`. A parse failure becomes `{ path, rel, parseError }`.
91
91
  - `parse(src, path, opts?)`, `walk(ast, visit)` — the lower-level AST seam.
92
92
  - `grep({ pattern | patterns, paths?, globs?, caseSensitive?, onlyMatching?,
93
93
  dedupe? })` — ripgrep matches as `{ path, lineNo, text, reason? }`, with
@@ -95,10 +95,10 @@ policy; the kit owns the mechanism:
95
95
  - `restatementDrift({ entries, equal })` — the shared "single source restated
96
96
  across consumers" scan + compare (service URLs, scalar values).
97
97
  - `enumDrift.build(registry)` / `enumDrift.seed(registry)` — the
98
- enumeration-drift engine: assert (or seed) that every consumer's fenced
99
- `<!-- enum:TOPIC:PROPERTY -->` block matches its source-of-truth set (an
100
- fs-glob or md-table probe). Pass a parsed topics registry (e.g.
101
- `config(topicsFile)`); pair with the rule kit's `enumDriftRules`.
98
+ enumeration-drift engine. It asserts (or seeds) that every consumer's
99
+ fenced `<!-- enum:TOPIC:PROPERTY -->` block matches its source-of-truth set
100
+ (an fs-glob or md-table probe). Pass a parsed topics registry (e.g.
101
+ `config(topicsFile)`). Pair it with the rule kit's `enumDriftRules`.
102
102
  - `readText`, `readJson`, `config(name, fallback?)` (co-located JSON/YAML),
103
103
  `listDir(path, { dirsOnly? })`.
104
104
  - `lineAt(text, offset)`, `glob(pattern)`.
@@ -107,30 +107,30 @@ policy; the kit owns the mechanism:
107
107
 
108
108
  When `rules` is a function it receives the rule helpers:
109
109
 
110
- - `parseError(scope, { id?, hint? })` — fails any subject carrying a
111
- `parseError` (paired with `scanAst`).
110
+ - `parseError(scope, { id?, hint? })` — fails any subject that carries a
111
+ `parseError` (pair it with `scanAst`).
112
112
  - `failAll(scope, { id, message, hint?, when? })` — fails every subject in
113
113
  scope (the build step already decided each is a violation).
114
- - `enumDriftRules` — the enumeration-drift rule set, paired with the build
115
- kit's `enumDrift` (expose via `rules: (kit) => kit.enumDriftRules`).
114
+ - `enumDriftRules` — the enumeration-drift rule set. Pair it with the build
115
+ kit's `enumDrift` (expose it with `rules: (kit) => kit.enumDriftRules`).
116
116
 
117
117
  Findings render in the same ESLint-style format across the handlers
118
- (`emitFindingsJson` for machine output); any finding fails the run.
118
+ (`emitFindingsJson` for machine output). Any finding fails the run.
119
119
 
120
120
  ## Documentation home
121
121
 
122
122
  libinvariant shares the **Run a Predictable Platform** job goal with the
123
- service-lifecycle libraries (librc, libsupervise, libtelemetry, libpreflight),
124
- but its full guide home is the Jidoka standard at
125
- <https://www.jidoka.team/> and [JIDOKA.md](../../JIDOKA.md), not the
126
- service-lifecycle guide tree under `websites/fit/docs/libraries/`.
127
-
128
- **Decision (2026-06-27):** this is deliberate scope separation, not a gap. The
129
- invariant checks run at **authoring time** against a repository's instruction
130
- layers and JTBD blocks; the service-lifecycle libraries run at **service
131
- runtime** against a live process. Mixing the two into one guide would blur the
123
+ service-lifecycle libraries (librc, libsupervise, libtelemetry, libpreflight).
124
+ Its full guide home is the Jidoka standard at <https://www.jidoka.team/> and
125
+ [JIDOKA.md](../../JIDOKA.md). The service-lifecycle guide tree under
126
+ `websites/fit/docs/libraries/` is not its guide home.
127
+
128
+ **Decision (2026-06-27):** this scope separation is deliberate. It is not a
129
+ gap. The invariant checks run at **authoring time** against a repository's
130
+ instruction layers and JTBD blocks. The service-lifecycle libraries run at
131
+ **service runtime** against a live process. One guide for both would blur the
132
132
  audience. The service-lifecycle Big Hire carries a one-line cross-link to the
133
- Jidoka standard so a reader who lands there can find this check, and that is
134
- the only link the service-lifecycle tree should carry. Future doc audits should
133
+ Jidoka standard, so a reader who lands there can find this check. The
134
+ service-lifecycle tree should carry no other link. In a future doc audit,
135
135
  treat the absence of a service-lifecycle guide page for the invariant checks
136
136
  as intended.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libinvariant",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Repository invariant checks — instruction-layer length caps, JTBD block validation, and a declarative rule-module runner over a caller-supplied rules directory.",
5
5
  "keywords": [
6
6
  "invariants",
@@ -43,8 +43,8 @@
43
43
  "dependencies": {
44
44
  "@forwardimpact/libcli": "^0.1.9",
45
45
  "@forwardimpact/libutil": "*",
46
- "acorn": "^8.17.0",
47
- "prettier": "^3.8.4",
46
+ "acorn": "^8.18.0",
47
+ "prettier": "^3.9.6",
48
48
  "yaml": "^2.9.0"
49
49
  },
50
50
  "devDependencies": {
@@ -1,15 +1,15 @@
1
1
  // The enumeration-drift grammar: source probes (fs-glob, md-table), the
2
2
  // list/count value extractors, and the fenced-block consumer parser. These are
3
- // the reusable mechanics the invariant kit injects as `kit.enumDrift`; they
4
- // carry no policy. Filesystem access is passed in (`fsSync`) rather than
5
- // imported, so this module stays clean under the repo's ambient-deps invariant;
6
- // the orchestration that binds them lives in enum-drift.js.
3
+ // the reusable mechanics the invariant kit injects as `kit.enumDrift`. They
4
+ // carry no policy. The caller passes filesystem access in (`fsSync`) instead of
5
+ // an import, so this module stays clean under the repo's ambient-deps
6
+ // invariant. The orchestration that binds them lives in enum-drift.js.
7
7
 
8
8
  import { isAbsolute, join } from "node:path";
9
9
 
10
10
  export const VALID_PROPERTIES = new Set(["count", "list"]);
11
11
 
12
- /** Reject a pattern/file that escapes `root` (absolute or `..`); else null.
12
+ /** Reject a pattern/file that escapes `root` (absolute or `..`), else null.
13
13
  *
14
14
  * @param {string} pattern - A repo-relative source pattern or file path.
15
15
  * @returns {string|null} An error message, or null when contained.
@@ -59,7 +59,7 @@ function splitGlob(pattern) {
59
59
  return { fixed: segments.slice(0, i), tail: segments.slice(i) };
60
60
  }
61
61
 
62
- /** Walk `tail`-deep below `dir`, matching each level, collecting ids. */
62
+ /** Walk `tail`-deep below `dir`. Match each level and collect ids. */
63
63
  function walkGlob(dir, tail, fixed, relParts, id, excludeSet, ids, fsSync) {
64
64
  if (relParts.length === tail.length) {
65
65
  const base = relParts[relParts.length - 1];
@@ -119,8 +119,8 @@ export function probeFsGlob(source, root, fsSync) {
119
119
  // --- md-table probe --------------------------------------------------------
120
120
 
121
121
  /**
122
- * Reduce a composite-action cell to its bare slug. Unwraps a leading
123
- * `[text](url)` markdown link to its link text, then drops backticks, the
122
+ * Reduce a composite-action cell to its bare slug. Unwrap a leading
123
+ * `[text](url)` markdown link to its link text. Then drop backticks, the
124
124
  * `forwardimpact/` scope, and a trailing `@version`.
125
125
  *
126
126
  * @param {string} cell - A raw table cell.
@@ -157,8 +157,8 @@ export function parseTableRow(line) {
157
157
  .map((c) => c.trim());
158
158
  }
159
159
 
160
- // The table rows under a `## <section>` heading, up to the next heading,
161
- // skipping fenced-code regions. Returns parsed cell-arrays (header included).
160
+ // The table rows under a `## <section>` heading, up to the next heading. This
161
+ // skips fenced-code regions and returns parsed cell-arrays (header included).
162
162
  function sectionTableRows(lines, section) {
163
163
  const headingRe = new RegExp(`^#{1,6}\\s+${escapeRegExp(section)}\\s*$`);
164
164
  let i = lines.findIndex((l) => headingRe.test(l));
@@ -178,7 +178,7 @@ function sectionTableRows(lines, section) {
178
178
  return rows;
179
179
  }
180
180
 
181
- /** Probe a md-table source: filtered column cells under a section heading.
181
+ /** Probe a md-table source for filtered column cells under a section heading.
182
182
  *
183
183
  * @param {{ file: string, section: string, column: string, filter: string }} source
184
184
  * @param {string} root - Repository root.
@@ -351,8 +351,8 @@ function bulletTokens(lines) {
351
351
  return out;
352
352
  }
353
353
 
354
- // First non-empty cell of each GFM data row, dropping the header (the row right
355
- // before the `|---|` alignment row).
354
+ // First non-empty cell of each GFM data row. This drops the header (the row
355
+ // right before the `|---|` alignment row).
356
356
  function tableIds(lines) {
357
357
  const rows = [];
358
358
  let alignmentAt = -1;
@@ -380,7 +380,7 @@ function tableIds(lines) {
380
380
  return ids;
381
381
  }
382
382
 
383
- // ASCII-tree leaf `name/ desc` — the trailing slash distinguishes a directory
383
+ // ASCII-tree leaf `name/ desc`. The trailing slash distinguishes a directory
384
384
  // leaf from a prose sentence whose first word is capitalized.
385
385
  function treeIds(lines) {
386
386
  const ids = new Set();
@@ -396,10 +396,10 @@ function treeIds(lines) {
396
396
 
397
397
  // A bare comma/space-separated run of inline code spans and nothing else, e.g.
398
398
  // `a`, `b`, `c`. Returns the code-span tokens, or null when the span carries
399
- // any other text (prose, a bullet marker, an item description). That ambiguity
400
- // — commas inside a description versus commas between items — is exactly what
401
- // the bracketed shapes resolve, so this fires only when the whole span is code
402
- // spans plus separators, and needs at least two so a lone token is not a list.
399
+ // any other text (prose, a bullet marker, an item description). Commas inside
400
+ // a description look like commas between items. The bracketed shapes resolve
401
+ // exactly that ambiguity. So this fires only when the whole span is code spans
402
+ // plus separators. It needs at least two, so a lone token is not a list.
403
403
  function inlineCodeSpanList(span) {
404
404
  const t = span.trim();
405
405
  const spans = t.match(/`[^`]+`/g);
@@ -411,8 +411,8 @@ function inlineCodeSpanList(span) {
411
411
  /**
412
412
  * Identifier set from a list-shaped span. Precedence: brace expansion, bullets,
413
413
  * a bare comma/space-separated run of code spans, GFM table, ASCII tree, then a
414
- * parenthetical comma-list (last resort, since a bullet/tree leaf often carries
415
- * a parenthetical aside whose commas are prose).
414
+ * parenthetical comma-list. The parenthetical comma-list comes last, because a
415
+ * bullet/tree leaf often carries a parenthetical aside whose commas are prose.
416
416
  *
417
417
  * @param {string} span - The fenced body to read.
418
418
  * @returns {Set<string>} The normalized identifier set.
package/src/enum-drift.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // The enumeration-drift engine: assert that every registered consumer's fenced
2
- // enumeration block matches its source-of-truth set. This is the reusable
3
- // mechanism the invariant kit injects — `kit.enumDrift.build/seed` and the
4
- // `enumDriftRules` rule set — so a repository's rule module carries only the
2
+ // enumeration block matches its source-of-truth set. The invariant kit injects
3
+ // this reusable mechanism as `kit.enumDrift.build/seed` and the
4
+ // `enumDriftRules` rule set. A repository's rule module then carries only the
5
5
  // registry (a topics file) and a one-line delegation. The grammar (probes,
6
- // extractors, consumer parser) lives in enum-drift-grammar.js and is re-exported
7
- // here so a single import reaches the whole engine. Filesystem access is passed
8
- // in by the kit (`fsSync`), keeping this clean under the ambient-deps invariant.
6
+ // extractors, consumer parser) lives in enum-drift-grammar.js. This module
7
+ // re-exports the grammar, so a single import reaches the whole engine. The kit
8
+ // passes filesystem access in (`fsSync`), which keeps this module clean under
9
+ // the ambient-deps invariant.
9
10
 
10
11
  import { join } from "node:path";
11
12
 
@@ -28,8 +29,8 @@ export {
28
29
  VALID_PROPERTIES,
29
30
  } from "./enum-drift-grammar.js";
30
31
 
31
- // A topics file is expected to name itself this way beside the rule module;
32
- // used only as the display path on a registry-error finding.
32
+ // The engine expects a topics file to use this name beside the rule module.
33
+ // This label appears only as the display path on a registry-error finding.
33
34
  const REGISTRY_LABEL = "enumeration-drift.topics.yml";
34
35
 
35
36
  function expandProperty(property) {
@@ -50,8 +51,8 @@ function indexConsumers(topic, propsByConsumer) {
50
51
  }
51
52
  }
52
53
 
53
- // Walk the registry topics, probing each source and indexing per-consumer
54
- // required properties; collects probe errors as registry subjects.
54
+ // Walk the registry topics. Probe each source and index the per-consumer
55
+ // required properties. Collect probe errors as registry subjects.
55
56
  function indexRegistry(topics, root, fsSync, registrySubjects) {
56
57
  const expectedByTopic = new Map();
57
58
  const propsByConsumer = new Map();
@@ -78,8 +79,8 @@ function indexRegistry(topics, root, fsSync, registrySubjects) {
78
79
  return { expectedByTopic, propsByConsumer, knownTopics };
79
80
  }
80
81
 
81
- // Emit assertion subjects for one consumer: the registry property is a required
82
- // minimum, and beyond that every well-formed fence found is asserted.
82
+ // Emit assertion subjects for one consumer. The registry property is a
83
+ // required minimum. Beyond that, it asserts every well-formed fence it finds.
83
84
  function consumerAssertions(cp, topicMap, records, expectedByTopic) {
84
85
  const out = [];
85
86
  for (const [topicId, props] of topicMap) {
@@ -130,8 +131,8 @@ function fenceSubjects(cp, records, knownTopics) {
130
131
  /**
131
132
  * Build subjects from a parsed registry: assertions (consumer×property),
132
133
  * fences, and registry errors. `registry` is the parsed topics object (e.g. the
133
- * kit's `config(topicsFile)`); a missing or malformed registry yields a single
134
- * registry-error subject rather than throwing.
134
+ * kit's `config(topicsFile)`). A missing or malformed registry yields a single
135
+ * registry-error subject. It does not throw.
135
136
  *
136
137
  * @param {{ registry: { topics?: object[] }|null, root: string, fsSync: object }} options
137
138
  * @returns {{ subjects: { assertion: object[], fence: object[], registry: object[] } }}
@@ -242,7 +243,7 @@ function symDiff(observed, expected) {
242
243
  }
243
244
 
244
245
  /**
245
- * The enumeration-drift rule set, injected into a rule module via the rule kit
246
+ * The enumeration-drift rule set. The rule kit injects it into a rule module
246
247
  * as `enumDriftRules`. The rules render the subjects `buildSubjects` produces.
247
248
  */
248
249
  export const ENUM_DRIFT_RULES = [
@@ -261,7 +262,7 @@ export const ENUM_DRIFT_RULES = [
261
262
  when: (s) => s.fenceAbsent,
262
263
  check: (s) => ({ topic: s.topic, property: s.property }),
263
264
  message: (s, r) => `${r.topic}:${r.property} :: required fence not found`,
264
- hint: "wrap the enumeration in <!-- enum:TOPIC:PROPERTY --> … <!-- /enum -->; seed the body with `jidoka invariants --seed enumeration-drift`",
265
+ hint: "wrap the enumeration in <!-- enum:TOPIC:PROPERTY --> … <!-- /enum -->, and seed the body with `jidoka invariants --seed enumeration-drift`",
265
266
  },
266
267
  {
267
268
  id: "enum.unknown-topic",
@@ -270,7 +271,7 @@ export const ENUM_DRIFT_RULES = [
270
271
  when: (s) => !s.malformed && s.topic !== null,
271
272
  check: (s) => (s.known ? null : { topic: s.topic }),
272
273
  message: (s, r) =>
273
- `${r.topic} :: unknown topic; remove the fence or add the topic to the registry`,
274
+ `${r.topic} :: unknown topic, so remove the fence or add the topic to the registry`,
274
275
  hint: "fence TOPIC must be one of the registry topic ids in the enumeration-drift topics file",
275
276
  },
276
277
  {
@@ -280,7 +281,7 @@ export const ENUM_DRIFT_RULES = [
280
281
  when: (s) => Boolean(s.malformed),
281
282
  check: (s) => ({ reason: s.malformed }),
282
283
  message: (s, r) => `malformed fence (${r.reason})`,
283
- hint: "fences are <!-- enum:TOPIC:count|list --> … <!-- /enum -->; close every open fence and put a number in a count span",
284
+ hint: "write fences as <!-- enum:TOPIC:count|list --> … <!-- /enum -->, close every open fence, and put a number in a count span",
284
285
  },
285
286
  {
286
287
  id: "enum.list-drift",
@@ -296,7 +297,7 @@ export const ENUM_DRIFT_RULES = [
296
297
  },
297
298
  message: (s, r) =>
298
299
  `${r.topic}:list :: missing=[${r.missing.join(", ")}] extra=[${r.extra.join(", ")}]`,
299
- hint: "update the fenced list to match the source set; seed with `jidoka invariants --seed enumeration-drift`",
300
+ hint: "update the fenced list to match the source set, and seed with `jidoka invariants --seed enumeration-drift`",
300
301
  },
301
302
  {
302
303
  id: "enum.count-drift",
@@ -310,6 +311,6 @@ export const ENUM_DRIFT_RULES = [
310
311
  : { topic: s.topic, actual: s.observed, expected: s.expected.size },
311
312
  message: (s, r) =>
312
313
  `${r.topic}:count :: actual=${r.actual} expected=${r.expected}`,
313
- hint: "update the fenced count to match the source set size; seed with `jidoka invariants --seed enumeration-drift`",
314
+ hint: "update the fenced count to match the source set size, and seed with `jidoka invariants --seed enumeration-drift`",
314
315
  },
315
316
  ];
@@ -23,12 +23,12 @@ const ITEM_SPLIT_RE = /^\s*-\s*\[[ xX]\]\s*/m;
23
23
  const lineCount = (text) => (text.match(/\n/g) || []).length;
24
24
  const wordCount = (text) => (text.match(/\S+/g) || []).length;
25
25
 
26
- // A leading YAML frontmatter block carries metadata, not instruction prose:
27
- // a skill's `name`/`description`, plus the `license` and `metadata` fields the
28
- // publish pipeline injects. Exclude it from the line/word budget so a published
29
- // copy of a layer counts the same as its in-repo source. Only a fenced block
30
- // that opens on the first line is stripped; the closing fence is the first
31
- // `---` line that follows.
26
+ // A leading YAML frontmatter block carries metadata. It does not carry
27
+ // instruction prose. It holds a skill's `name`/`description`, plus the
28
+ // `license` and `metadata` fields the publish pipeline injects. Exclude it from
29
+ // the line/word budget so a published copy of a layer counts the same as its
30
+ // in-repo source. This strips only a fenced block that opens on the first line.
31
+ // The closing fence is the first `---` line that follows.
32
32
  const FRONTMATTER_RE = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/;
33
33
  const stripFrontmatter = (text) => text.replace(FRONTMATTER_RE, "");
34
34
 
@@ -81,16 +81,16 @@ async function findByName(root, name, kind, fs) {
81
81
  }
82
82
 
83
83
  // A `.claude/agents/*.md` file is a profile when it carries both `name` and
84
- // `description` frontmatter — the same test Claude Code's agent loader applies
85
- // to decide what loads as an agent — and a reference otherwise. This replaces
84
+ // `description` frontmatter. Otherwise it is a reference. Claude Code's agent
85
+ // loader applies the same test to decide what loads as an agent. This replaces
86
86
  // the old references-subdirectory marker, which APM flattens away.
87
87
  const isProfile = (text) =>
88
88
  /^name:[ \t]*\S/m.test(text) && /^description:[ \t]*\S/m.test(text);
89
89
 
90
90
  /**
91
91
  * Partition the flat `agents/*.md` listing into profiles (L3) and references
92
- * (L4) by frontmatter. Reads each file once and shares the read between the
93
- * two layers, replacing the former separate directory walks.
92
+ * (L4) by frontmatter. This reads each file once and shares the read between
93
+ * the two layers. It replaces the former separate directory walks.
94
94
  */
95
95
  async function partitionAgents(root, claudeDirs, fs) {
96
96
  const profiles = [];
@@ -190,27 +190,7 @@ async function buildLayers(root, fs) {
190
190
  name: "agent reference",
191
191
  maxLines: 192,
192
192
  maxWords: 1280,
193
- files: agentReferences.filter(
194
- (p) => !p.endsWith("/agents/x-memory-protocol.md"),
195
- ),
196
- },
197
- {
198
- id: "L4",
199
- name: "memory-protocol agent reference",
200
- // Larger than the L4 default to absorb the two durable surfaces this
201
- // one reference is the sole home for: the boot-digest routing contract
202
- // (materialized agent-experiments surface with provenance fields and a
203
- // last-successful-sync freshness bound, plus the verbatim
204
- // standing-carries digest field) and the canonical Carry Surface
205
- // section (a durable per-Assess obligation surface kept off the summary
206
- // budget, beside the On-Boot Read Set it extends). Both are load-bearing
207
- // memory-protocol concepts. Sized to the current content, not
208
- // open-ended.
209
- maxLines: 216,
210
- maxWords: 1588,
211
- files: agentReferences.filter((p) =>
212
- p.endsWith("/agents/x-memory-protocol.md"),
213
- ),
193
+ files: agentReferences,
214
194
  },
215
195
  {
216
196
  id: "L5",
@@ -225,11 +205,11 @@ async function buildLayers(root, fs) {
225
205
  id: "L5",
226
206
  name: "kata-release-merge skill procedure",
227
207
  // Larger than the L5 default to absorb four consolidated merge-gate
228
- // rule sets that govern adjacent corners of one gate: phase-PR review
208
+ // rule sets. They govern adjacent corners of one gate: phase-PR review
229
209
  // transfer (pin-based head coverage), post-panel coverage (folded into
230
- // the pin mechanism rather than duplicated), the spec-less
231
- // experiment-PR approval path, and the block-comment re-ping cadence.
232
- // Sized to the consolidated content, not open-ended.
210
+ // the pin mechanism rather than duplicated), the approval path for a
211
+ // spec-less experiment PR, and the block-comment re-ping cadence.
212
+ // This limit fits the consolidated content. It is not open-ended.
233
213
  maxLines: 320,
234
214
  maxWords: 2304,
235
215
  files: skillDirs
@@ -263,7 +243,7 @@ async function buildFileSubjects(root, layers, fs) {
263
243
  for (const relPath of layer.files) {
264
244
  const text = await readText(root, relPath, fs);
265
245
  if (text == null) continue;
266
- // Budget the instruction prose only — metadata frontmatter is exempt.
246
+ // Budget the instruction prose only. Metadata frontmatter is exempt.
267
247
  const body = stripFrontmatter(text);
268
248
  subjects.push({
269
249
  path: resolve(root, relPath),
@@ -303,7 +283,7 @@ async function buildChecklistSubjects(root, sources, fs) {
303
283
  }
304
284
 
305
285
  const HINT_LAYER_BUDGET =
306
- "trim prose to fit the layer cap — see JIDOKA.md for the layered-instruction model";
286
+ "trim prose to fit the layer cap, and see JIDOKA.md for the layered-instruction model";
307
287
 
308
288
  // -- Rule catalogue ------------------------------------------------------
309
289
 
@@ -357,18 +337,18 @@ export const INSTRUCTION_RULES = [
357
337
  },
358
338
  message: (s, r) =>
359
339
  `checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
360
- hint: "rewrite the item more concisely — checklist items are pointers, not explanations",
340
+ hint: "rewrite the item more concisely, because a checklist item is a pointer and does not explain",
361
341
  },
362
342
  ];
363
343
 
364
344
  // -- Public entry --------------------------------------------------------
365
345
 
366
346
  /**
367
- * Walk the repo rooted at `root`, applying the L1–L7 caps from JIDOKA.md.
368
- * Each layer is gated by a line cap AND a word cap; either breach fails.
347
+ * Walk the repo rooted at `root` and apply the L1–L7 caps from JIDOKA.md.
348
+ * A line cap AND a word cap gate each layer. Either breach fails.
369
349
  *
370
350
  * @param {{ root: string, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
371
- * @returns {Promise<Finding[]>} Structured findings; empty when conformant.
351
+ * @returns {Promise<Finding[]>} Structured findings, empty when conformant.
372
352
  * Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
373
353
  * with `emitFindingsText` / `emitFindingsJson` from libutil.
374
354
  */
@@ -1,14 +1,16 @@
1
- // The invariant authoring kit: the mechanism an invariant rule module needs to
2
- // turn the repository into subjects and findings, so the module itself carries
3
- // only policy. The engine injects a *build kit* into every module's `build`
4
- // (and `seed`), and a *rule kit* into a module's `rules` when it is written as
5
- // a function. Modules never import this file — the host (invariants.js) binds a
6
- // kit per run and passes it in, the same way the rest of the monorepo threads
7
- // the `runtime` bag instead of importing ambient collaborators.
1
+ // The invariant kit: the mechanism an invariant rule module needs to turn the
2
+ // repository into subjects and findings, so the module itself carries only
3
+ // policy. The engine injects a *build kit* into every module's `build` (and
4
+ // `seed`). It injects a *rule kit* into a module's `rules` when the module
5
+ // exports `rules` as a function. Modules never import this file. The host
6
+ // (invariants.js) binds a kit per run and passes it in. The rest of the
7
+ // monorepo threads the `runtime` bag the same way and does not import ambient
8
+ // collaborators.
8
9
  //
9
- // Filesystem and subprocess access route through the injected `runtime`
10
- // (`runtime.fsSync`, `runtime.subprocess`) so this module — which lives under
11
- // libraries/<pkg>/src — stays clean under the repo's own ambient-deps invariant.
10
+ // This module lives under libraries/<pkg>/src. Filesystem and subprocess
11
+ // access route through the injected `runtime` (`runtime.fsSync`,
12
+ // `runtime.subprocess`), so the module stays clean under the repo's own
13
+ // ambient-deps invariant.
12
14
 
13
15
  import { isAbsolute, join, relative, resolve } from "node:path";
14
16
 
@@ -20,7 +22,7 @@ import { buildSubjects, ENUM_DRIFT_RULES, seedBodies } from "./enum-drift.js";
20
22
  // -- AST -----------------------------------------------------------------
21
23
 
22
24
  /**
23
- * Parse an ES module, wrapping acorn's error with the offending path.
25
+ * Parse an ES module. Wrap acorn's error with the offending path.
24
26
  *
25
27
  * @param {string} source - Module source text.
26
28
  * @param {string} filePath - Path used in parse-error messages.
@@ -41,10 +43,10 @@ export function parse(source, filePath, { locations = false } = {}) {
41
43
  }
42
44
 
43
45
  /**
44
- * Depth-first visit of every typed node in an acorn AST.
46
+ * Visit every typed node in an acorn AST, depth first.
45
47
  *
46
48
  * @param {object|object[]} node - AST node (or array of nodes).
47
- * @param {(node: object) => void} visit - Called once per typed node.
49
+ * @param {(node: object) => void} visit - Runs once per typed node.
48
50
  */
49
51
  export function walk(node, visit) {
50
52
  if (!node || typeof node !== "object") return;
@@ -78,7 +80,7 @@ export function lineAt(text, offset) {
78
80
  }
79
81
 
80
82
  /**
81
- * Compile a minimal path glob to a `RegExp`. `**` matches any path segments;
83
+ * Compile a minimal path glob to a `RegExp`. `**` matches any path segments.
82
84
  * `*` matches a non-slash run.
83
85
  *
84
86
  * @param {string} pattern - The glob.
@@ -94,7 +96,7 @@ export function glob(pattern) {
94
96
  );
95
97
  }
96
98
 
97
- // -- Filesystem walking (over runtime.fsSync) ----------------------------
99
+ // -- Filesystem walk (over runtime.fsSync) -------------------------------
98
100
 
99
101
  function collectFiles(fsSync, dir, skip, match) {
100
102
  const out = [];
@@ -151,7 +153,7 @@ function dedupeRows(rows, dedupe) {
151
153
  });
152
154
  }
153
155
 
154
- // A grep row carries `rel`/`raw` for dedupe; the subject keeps only the
156
+ // A grep row carries `rel`/`raw` for dedupe. The subject keeps only the
155
157
  // reportable fields (plus `reason` when the matching entry supplied one).
156
158
  function toGrepSubject({ path, lineNo, text, reason }) {
157
159
  const subject = { path, lineNo, text };
@@ -162,9 +164,10 @@ function toGrepSubject({ path, lineNo, text, reason }) {
162
164
  // -- The build kit -------------------------------------------------------
163
165
 
164
166
  /**
165
- * Build the kit injected into a rule module's `build` and `seed`. Every
166
- * collaborator is bound to `root` (the repository root), `dir` (the module's
167
- * own directory, for co-located config), and `runtime` (the ambient bag).
167
+ * Build the kit that the engine injects into a rule module's `build` and
168
+ * `seed`. This binds every collaborator to `root` (the repository root), `dir`
169
+ * (the module's own directory, for co-located config), and `runtime` (the
170
+ * ambient bag).
168
171
  *
169
172
  * @param {{ root: string, dir: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
170
173
  * @returns {object} The build kit.
@@ -227,7 +230,7 @@ export function createBuildKit({ root, dir, runtime }) {
227
230
 
228
231
  function assertRg() {
229
232
  if (subprocess.runSync("rg", ["--version"]).exitCode !== 0) {
230
- throw new Error("ripgrep (rg) is required by the invariant rule modules");
233
+ throw new Error("the invariant rule modules require ripgrep (rg)");
231
234
  }
232
235
  }
233
236
 
@@ -252,15 +255,15 @@ export function createBuildKit({ root, dir, runtime }) {
252
255
  /**
253
256
  * Scan the repo with ripgrep and return matches as subjects. Accepts one
254
257
  * `pattern` or a list of `patterns` (strings, or `{ pattern, reason?, globs?,
255
- * caseSensitive?, onlyMatching?, exclude? }`); per-entry options override the
256
- * call defaults, and a per-entry `exclude` RegExp drops matches whose raw
257
- * line it tests true (a false-positive filter). `dedupe` is `false`, `true`
258
- * (key on the raw line), or a key function over `{ path, rel, lineNo, text,
259
- * raw, reason }`.
258
+ * caseSensitive?, onlyMatching?, exclude? }`). Per-entry options override the
259
+ * call defaults. A per-entry `exclude` RegExp drops matches whose raw line it
260
+ * tests true (a false-positive filter). `dedupe` is `false`, `true` (key on
261
+ * the raw line), or a key function over `{ path, rel, lineNo, text, raw,
262
+ * reason }`.
260
263
  *
261
264
  * Each subject is `{ path, lineNo, text }`, plus `reason` when the matching
262
265
  * entry carries one. The repo-relative `rel` and full `raw` line are
263
- * available to the `dedupe` key function but are not part of the subject.
266
+ * available to the `dedupe` key function. They are not part of the subject.
264
267
  *
265
268
  * @param {object} options
266
269
  * @returns {Array<{ path: string, lineNo: number, text: string, reason?: string }>}
@@ -303,7 +306,7 @@ export function createBuildKit({ root, dir, runtime }) {
303
306
  }
304
307
 
305
308
  /**
306
- * Read and parse a repo JSON file, returning `null` when missing or invalid.
309
+ * Read and parse a repo JSON file. Return `null` when missing or invalid.
307
310
  *
308
311
  * @param {string} path - Relative to the repo root, or absolute.
309
312
  * @returns {object|null}
@@ -319,9 +322,9 @@ export function createBuildKit({ root, dir, runtime }) {
319
322
  }
320
323
 
321
324
  /**
322
- * Read a config file co-located with the rule module (`<dir>/<name>`),
323
- * parsed by extension (`.json` / `.yml` / `.yaml`). Returns `fallback` when
324
- * the file is missing, empty, or unparseable.
325
+ * Read a config file co-located with the rule module (`<dir>/<name>`). The
326
+ * extension (`.json` / `.yml` / `.yaml`) selects the parser. Returns
327
+ * `fallback` when the file is missing, empty, or unparseable.
325
328
  *
326
329
  * @param {string} name - File name beside the module.
327
330
  * @param {*} [fallback] - Value when absent or unreadable (default `null`).
@@ -343,15 +346,16 @@ export function createBuildKit({ root, dir, runtime }) {
343
346
  /**
344
347
  * The shared "single source restated across consumers" check. For every
345
348
  * registry `entry` (`{ key, expected, consumers: [{ path, pattern }] }`),
346
- * scan each consumer file line by line for `pattern` and emit one subject
347
- * per match: the restated value (capture group 1, else the whole match,
348
- * trimmed) paired with the entry's `expected` value and an `ok` verdict from
349
- * `equal(restated, expected, key)`. The module supplies the domain pieces
350
- * (how `expected` is computed, what `equal` means); the kit owns the scan.
349
+ * scan each consumer file line by line for `pattern`. Emit one subject per
350
+ * match. The subject holds the restated value (capture group 1, else the
351
+ * whole match, trimmed), the entry's `expected` value, and an `ok` verdict
352
+ * from `equal(restated, expected, key)`. The module supplies the domain
353
+ * pieces (how it computes `expected`, what `equal` means). The kit owns the
354
+ * scan.
351
355
  *
352
- * Native line-by-line matching, not ripgrep: the surfaces carry URLs and
353
- * other colon-bearing values that ripgrep's single-file output corrupts, and
354
- * look-around is sometimes needed.
356
+ * This matches line by line and does not use ripgrep. The surfaces carry
357
+ * URLs and other colon-bearing values that ripgrep's single-file output
358
+ * corrupts. Look-around is sometimes necessary.
355
359
  *
356
360
  * @param {object} options
357
361
  * @param {Array<{ key: string, expected: *, consumers: Array<{ path: string, pattern: RegExp|string }> }>} options.entries
@@ -387,7 +391,7 @@ export function createBuildKit({ root, dir, runtime }) {
387
391
  }
388
392
 
389
393
  /**
390
- * List the entries of a repo directory by name, returning `[]` when missing.
394
+ * List the entries of a repo directory by name. Return `[]` when missing.
391
395
  *
392
396
  * @param {string} path - Relative to the repo root, or absolute.
393
397
  * @param {{ dirsOnly?: boolean, filesOnly?: boolean }} [options]
@@ -409,7 +413,7 @@ export function createBuildKit({ root, dir, runtime }) {
409
413
 
410
414
  // The enumeration-drift engine, bound to this run's root and filesystem. A
411
415
  // rule module passes its parsed registry (e.g. `config(topicsFile)`) to
412
- // `build`/`seed`; the matching rule set is on the rule kit as
416
+ // `build`/`seed`. The rule kit carries the matching rule set as
413
417
  // `enumDriftRules`.
414
418
  const enumDrift = {
415
419
  build: (registry) => buildSubjects({ registry, root, fsSync }),
@@ -440,19 +444,19 @@ export function createBuildKit({ root, dir, runtime }) {
440
444
 
441
445
  /**
442
446
  * Helpers a rule module receives when it exports `rules` as a function. They
443
- * build the two recurring rule shapes so the module declares only policy.
447
+ * build the two rule shapes that recur, so the module declares only policy.
444
448
  */
445
449
  export const RULE_KIT = {
446
450
  /**
447
451
  * The enumeration-drift rule set, paired with the build kit's `enumDrift`. A
448
- * rule module that delegates to `kit.enumDrift` exposes these via
452
+ * rule module that delegates to `kit.enumDrift` exposes these through
449
453
  * `rules: (kit) => kit.enumDriftRules`.
450
454
  */
451
455
  enumDriftRules: ENUM_DRIFT_RULES,
452
456
 
453
457
  /**
454
- * The standard parse-error rule: fails any subject carrying a `parseError`
455
- * string (as produced by the build kit's `scanAst`).
458
+ * The standard parse-error rule. It fails any subject that carries a
459
+ * `parseError` string. The build kit's `scanAst` produces that string.
456
460
  *
457
461
  * @param {string} scope - The subject scope to guard.
458
462
  * @param {{ id?: string, hint?: string }} [options]
@@ -465,13 +469,13 @@ export const RULE_KIT = {
465
469
  severity: "fail",
466
470
  check: (s) => (s.parseError ? { msg: s.parseError } : null),
467
471
  message: (_s, r) => r.msg,
468
- hint: hint ?? "fix the syntax error so the file can be parsed",
472
+ hint: hint ?? "fix the syntax error so the parser can read the file",
469
473
  };
470
474
  },
471
475
 
472
476
  /**
473
477
  * A rule that fails every subject in `scope` (optionally gated by `when`).
474
- * The build step has already decided each subject is a violation; the rule
478
+ * The build step already decided that each subject is a violation. The rule
475
479
  * only renders it.
476
480
  *
477
481
  * @param {string} scope
package/src/invariants.js CHANGED
@@ -6,17 +6,18 @@ import { LIBCLI_IS_COMPILED } from "@forwardimpact/libcli";
6
6
  import { runRules } from "@forwardimpact/libutil";
7
7
  import { createBuildKit, RULE_KIT } from "./invariant-kit.js";
8
8
 
9
- // Rule modules are imported dynamically at runtime, so a compiled binary cannot
10
- // bundle their bare imports, and the standalone executable has no node_modules
11
- // to resolve them from — a rule module's `import "yaml"` would fail. Expose the
12
- // third-party packages libinvariant already bundles as virtual modules, so a
13
- // compiled rule module resolves them to the embedded copies. Under node/bunx
14
- // this is a no-op: node_modules resolves them normally. A rule module that
15
- // imports a package beyond this set must run via the package, not the binary.
9
+ // The loader imports rule modules dynamically at runtime. So a compiled binary
10
+ // cannot bundle their bare imports. The standalone executable also has no
11
+ // node_modules to resolve them from, so a rule module's `import "yaml"` would
12
+ // fail. Expose the third-party packages libinvariant already bundles as virtual
13
+ // modules. A compiled rule module then resolves them to the embedded copies.
14
+ // Under node/bunx this is a no-op, because node_modules resolves them normally.
15
+ // A rule module that imports a package beyond this set must run through the
16
+ // package. It cannot run through the binary.
16
17
  let bundledRuleDepsRegistered = false;
17
18
  function registerBundledRuleDeps() {
18
19
  if (bundledRuleDepsRegistered) return;
19
- // Only the standalone binary needs this; node/bunx resolve from node_modules.
20
+ // Only the standalone binary needs this. node/bunx resolve from node_modules.
20
21
  if (!LIBCLI_IS_COMPILED || typeof Bun === "undefined") {
21
22
  return;
22
23
  }
@@ -32,20 +33,20 @@ function registerBundledRuleDeps() {
32
33
 
33
34
  /**
34
35
  * Resolve the root whose rules directory applies to the working directory.
35
- * The nearest `package.json` is not enough — inside a monorepo every
36
- * workspace package has one — so search upward for the caller-supplied
37
- * rules directory itself, falling back to the nearest project root so the
38
- * loader's error names the expected location.
36
+ * The nearest `package.json` is not enough. Inside a monorepo every workspace
37
+ * package has one. So search upward for the caller-supplied rules directory
38
+ * itself. Fall back to the nearest project root so the loader's error names
39
+ * the expected location.
39
40
  *
40
41
  * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
41
42
  * @param {string} rulesDir - Rules directory relative to the project root.
42
- * @returns {string} Project root directory path.
43
+ * @returns {string} Path of the project root directory.
43
44
  */
44
45
  export function findInvariantsRoot(runtime, rulesDir) {
45
46
  const found = runtime.finder.findUpward(runtime.proc.cwd(), rulesDir, 8);
46
47
  if (!found) return runtime.finder.findProjectRoot();
47
- // Climb back out of the found rules directory — one level per segment of
48
- // the caller-supplied path — to land on the project root that contains it.
48
+ // Climb back out of the found rules directory to the project root that
49
+ // contains it. Use one level for each segment of the caller-supplied path.
49
50
  const climb = rulesDir
50
51
  .split("/")
51
52
  .filter(Boolean)
@@ -64,10 +65,10 @@ export function findInvariantsRoot(runtime, rulesDir) {
64
65
  // seed?: async ({ root, runtime }) => "text", // e.g. a refreshed deny-list
65
66
  // }
66
67
  //
67
- // `build` walks the repo and returns plain subjects per scope; `rules` are the
68
- // declarative checks `runRules` applies over them. The repository owns its
69
- // rule modules — this host only discovers, loads, and runs them, so the
70
- // policies themselves never ship with the CLI.
68
+ // `build` walks the repo and returns plain subjects for each scope. `rules`
69
+ // are the declarative checks that `runRules` applies over them. The repository
70
+ // owns its rule modules. This host only discovers, loads, and runs them. So
71
+ // the policies themselves never ship with the CLI.
71
72
 
72
73
  function assertModuleShape(mod, fileName) {
73
74
  const ok =
@@ -89,12 +90,12 @@ function resolveRules(mod) {
89
90
  }
90
91
 
91
92
  /**
92
- * Discover and import every rule module under `rulesDir` (sorted by file
93
- * name for a stable run order).
93
+ * Discover and import every rule module under `rulesDir`. Sort by file name
94
+ * for a stable run order.
94
95
  *
95
96
  * @param {{ root: string, rulesDir: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
96
- * `rulesDir` is the rules directory relative to `root`, supplied by the
97
- * caller (the library carries no discovery default).
97
+ * `rulesDir` is the rules directory relative to `root`. The caller supplies
98
+ * it. The library carries no discovery default.
98
99
  * @returns {Promise<object[]>} The modules' default exports.
99
100
  */
100
101
  export async function loadRuleModules({ root, rulesDir, runtime }) {
@@ -123,14 +124,14 @@ export async function loadRuleModules({ root, rulesDir, runtime }) {
123
124
  }
124
125
 
125
126
  /**
126
- * Run already-loaded rule modules: inject the build kit, build each module's
127
- * subjects, then apply its rule catalogue through the shared rules engine.
127
+ * Run already-loaded rule modules. Inject the build kit. Build each module's
128
+ * subjects. Then apply its rule catalogue through the shared rules engine.
128
129
  *
129
130
  * @param {object[]} modules - Rule-module default exports.
130
131
  * @param {{ root: string, runtime: import('@forwardimpact/libutil/runtime').Runtime, dir: string }} options
131
- * `dir` is the modules' directory (for co-located config), supplied by
132
- * the caller alongside the rules it loaded.
133
- * @returns {Promise<object[]>} Structured findings; empty when conformant.
132
+ * `dir` is the modules' directory for co-located config. The caller supplies
133
+ * it with the rules it loaded.
134
+ * @returns {Promise<object[]>} Structured findings, empty when conformant.
134
135
  */
135
136
  export async function runRuleModules(modules, { root, runtime, dir }) {
136
137
  const findings = [];
@@ -152,7 +153,7 @@ export async function runRuleModules(modules, { root, runtime, dir }) {
152
153
  * Load every rule module under `root`/`rulesDir` and run it.
153
154
  *
154
155
  * @param {{ root: string, rulesDir: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
155
- * @returns {Promise<object[]>} Structured findings; empty when conformant.
156
+ * @returns {Promise<object[]>} Structured findings, empty when conformant.
156
157
  * Each finding is `{ id, level, path, lineNo?, message, hint? }` for use
157
158
  * with `emitFindingsText` / `emitFindingsJson` from libutil.
158
159
  */
package/src/jtbd.js CHANGED
@@ -66,7 +66,7 @@ export const JTBD_RULES = [
66
66
  severity: "fail",
67
67
  check: (s) => (Array.isArray(s.jobs) ? null : {}),
68
68
  message: () => ".jobs must be an array",
69
- hint: "wrap the value in [] — even a single job is an array of one",
69
+ hint: "wrap the value in [], because even a single job is an array of one",
70
70
  },
71
71
  {
72
72
  id: "jtbd.invalid-user",
@@ -121,7 +121,7 @@ export const JTBD_RULES = [
121
121
  hint: "append a period to the hire sentence",
122
122
  },
123
123
  {
124
- // Cross-entry uniqueness — mutates ctx.allHires across iterations.
124
+ // Cross-entry uniqueness. This rule mutates ctx.allHires across iterations.
125
125
  id: "jtbd.duplicate-hire",
126
126
  scope: "jtbd-entry",
127
127
  severity: "fail",
@@ -334,8 +334,8 @@ function makeFormatter(prettierConfig) {
334
334
  ...prettierConfig,
335
335
  parser: "markdown",
336
336
  });
337
- // Prettier inserts a blank line between bold labels and bullet lists;
338
- // remove it to match the hand-written JTBD.md style.
337
+ // Prettier inserts a blank line between bold labels and bullet lists.
338
+ // Remove it to match the hand-written JTBD.md style.
339
339
  return formatted.replace(/\*\*\n\n(- )/g, "**\n$1").trimEnd();
340
340
  };
341
341
  }
@@ -491,15 +491,15 @@ async function processJtbdMd(root, fix, formatMarkdown, result, fsSync) {
491
491
 
492
492
  /**
493
493
  * Validate every `package.json .jobs` entry under products/, services/, and
494
- * libraries/, and (when `fix` is true) regenerate the marker-delimited catalog,
494
+ * libraries/. When `fix` is true, regenerate the marker-delimited catalog,
495
495
  * jobs, and description blocks in the corresponding README.md and JTBD.md.
496
496
  *
497
497
  * @param {{ root: string, fix?: boolean, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
498
498
  * @returns {Promise<{ findings: Finding[], stale: string[], fixed: string[] }>}
499
- * `findings` are validation failures (structured for `emitFindingsText` /
500
- * `emitFindingsJson` from libutil); `stale` is files whose generated blocks
501
- * are out of date (only populated when `fix` is false); `fixed` is files
502
- * that were rewritten in place.
499
+ * `findings` are validation failures, structured for `emitFindingsText` /
500
+ * `emitFindingsJson` from libutil. `stale` is files whose generated blocks
501
+ * are out of date, and it holds entries only when `fix` is false. `fixed`
502
+ * is files that `checkJtbd` rewrote in place.
503
503
  */
504
504
  export async function checkJtbd({ root, fix = false, runtime }) {
505
505
  if (!runtime) throw new Error("runtime is required");