@forwardimpact/libinvariant 0.2.0 → 0.2.1
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 +42 -42
- package/package.json +3 -3
- package/src/enum-drift-grammar.js +20 -20
- package/src/enum-drift.js +21 -20
- package/src/instructions.js +28 -28
- package/src/invariant-kit.js +50 -46
- package/src/invariants.js +30 -29
- package/src/jtbd.js +9 -9
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ directory.
|
|
|
10
10
|
|
|
11
11
|
## Getting Started
|
|
12
12
|
|
|
13
|
-
libinvariant is an import-only library
|
|
14
|
-
hire the Jidoka product
|
|
15
|
-
`jidoka` binary
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
38
|
-
JTBD.md, L3 agent profile, L4 agent
|
|
39
|
-
reference,
|
|
40
|
-
|
|
41
|
-
- `checkJtbd` — each `package.json .jobs` entry
|
|
42
|
-
schema
|
|
43
|
-
`<dir>/<pkg>/README.md`, and root `JTBD.md
|
|
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
|
|
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
|
|
51
|
-
|
|
52
|
-
The policies stay in the repository
|
|
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
|
|
83
|
-
|
|
84
|
-
policy
|
|
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? }
|
|
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)
|
|
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
|
|
99
|
-
`<!-- enum:TOPIC:PROPERTY -->` block matches its source-of-truth set
|
|
100
|
-
fs-glob or md-table probe). Pass a parsed topics registry (e.g.
|
|
101
|
-
`config(topicsFile)`)
|
|
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
|
|
111
|
-
`parseError` (
|
|
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
|
|
115
|
-
kit's `enumDrift` (expose
|
|
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)
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
**Decision (2026-06-27):** this is deliberate
|
|
129
|
-
invariant checks run at **authoring time** against a repository's
|
|
130
|
-
layers and JTBD blocks
|
|
131
|
-
runtime** against a live process.
|
|
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
|
|
134
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
47
|
-
"prettier": "^3.
|
|
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
|
|
4
|
-
// carry no policy.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 `..`)
|
|
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
|
|
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.
|
|
123
|
-
* `[text](url)` markdown link to its link text
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
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
|
|
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.
|
|
3
|
-
//
|
|
4
|
-
// `enumDriftRules` rule set
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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
|
|
54
|
-
// required properties
|
|
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
|
|
82
|
-
// minimum,
|
|
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)`)
|
|
134
|
-
* registry-error subject
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
314
|
+
hint: "update the fenced count to match the source set size, and seed with `jidoka invariants --seed enumeration-drift`",
|
|
314
315
|
},
|
|
315
316
|
];
|
package/src/instructions.js
CHANGED
|
@@ -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
|
|
27
|
-
// a skill's `name`/`description`, plus the
|
|
28
|
-
// publish pipeline injects. Exclude it from
|
|
29
|
-
// copy of a layer counts the same as its
|
|
30
|
-
//
|
|
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
|
|
85
|
-
// to decide what loads as an agent
|
|
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.
|
|
93
|
-
* two layers
|
|
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 = [];
|
|
@@ -198,14 +198,14 @@ async function buildLayers(root, fs) {
|
|
|
198
198
|
id: "L4",
|
|
199
199
|
name: "memory-protocol agent reference",
|
|
200
200
|
// Larger than the L4 default to absorb the two durable surfaces this
|
|
201
|
-
// one reference is the sole home for
|
|
202
|
-
//
|
|
203
|
-
// last-successful-sync freshness bound, plus
|
|
204
|
-
// standing-carries digest field
|
|
205
|
-
// section
|
|
206
|
-
// budget, beside the On-Boot
|
|
207
|
-
//
|
|
208
|
-
// open-ended.
|
|
201
|
+
// one reference is the sole home for. The first is the boot-digest
|
|
202
|
+
// routing contract. It is a materialized agent-experiments surface with
|
|
203
|
+
// provenance fields and a last-successful-sync freshness bound, plus
|
|
204
|
+
// the verbatim standing-carries digest field. The second is the
|
|
205
|
+
// canonical Carry Surface section. It is a durable per-Assess
|
|
206
|
+
// obligation surface kept off the summary budget, beside the On-Boot
|
|
207
|
+
// Read Set it extends. Both are load-bearing memory-protocol concepts.
|
|
208
|
+
// This limit fits the current content. It is not open-ended.
|
|
209
209
|
maxLines: 216,
|
|
210
210
|
maxWords: 1588,
|
|
211
211
|
files: agentReferences.filter((p) =>
|
|
@@ -225,11 +225,11 @@ async function buildLayers(root, fs) {
|
|
|
225
225
|
id: "L5",
|
|
226
226
|
name: "kata-release-merge skill procedure",
|
|
227
227
|
// Larger than the L5 default to absorb four consolidated merge-gate
|
|
228
|
-
// rule sets
|
|
228
|
+
// rule sets. They govern adjacent corners of one gate: phase-PR review
|
|
229
229
|
// transfer (pin-based head coverage), post-panel coverage (folded into
|
|
230
|
-
// the pin mechanism rather than duplicated), the
|
|
231
|
-
//
|
|
232
|
-
//
|
|
230
|
+
// the pin mechanism rather than duplicated), the approval path for a
|
|
231
|
+
// spec-less experiment PR, and the block-comment re-ping cadence.
|
|
232
|
+
// This limit fits the consolidated content. It is not open-ended.
|
|
233
233
|
maxLines: 320,
|
|
234
234
|
maxWords: 2304,
|
|
235
235
|
files: skillDirs
|
|
@@ -263,7 +263,7 @@ async function buildFileSubjects(root, layers, fs) {
|
|
|
263
263
|
for (const relPath of layer.files) {
|
|
264
264
|
const text = await readText(root, relPath, fs);
|
|
265
265
|
if (text == null) continue;
|
|
266
|
-
// Budget the instruction prose only
|
|
266
|
+
// Budget the instruction prose only. Metadata frontmatter is exempt.
|
|
267
267
|
const body = stripFrontmatter(text);
|
|
268
268
|
subjects.push({
|
|
269
269
|
path: resolve(root, relPath),
|
|
@@ -303,7 +303,7 @@ async function buildChecklistSubjects(root, sources, fs) {
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
const HINT_LAYER_BUDGET =
|
|
306
|
-
"trim prose to fit the layer cap
|
|
306
|
+
"trim prose to fit the layer cap, and see JIDOKA.md for the layered-instruction model";
|
|
307
307
|
|
|
308
308
|
// -- Rule catalogue ------------------------------------------------------
|
|
309
309
|
|
|
@@ -357,18 +357,18 @@ export const INSTRUCTION_RULES = [
|
|
|
357
357
|
},
|
|
358
358
|
message: (s, r) =>
|
|
359
359
|
`checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
|
|
360
|
-
hint: "rewrite the item more concisely
|
|
360
|
+
hint: "rewrite the item more concisely, because a checklist item is a pointer and does not explain",
|
|
361
361
|
},
|
|
362
362
|
];
|
|
363
363
|
|
|
364
364
|
// -- Public entry --------------------------------------------------------
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
|
-
* Walk the repo rooted at `root
|
|
368
|
-
*
|
|
367
|
+
* Walk the repo rooted at `root` and apply the L1–L7 caps from JIDOKA.md.
|
|
368
|
+
* A line cap AND a word cap gate each layer. Either breach fails.
|
|
369
369
|
*
|
|
370
370
|
* @param {{ root: string, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
|
|
371
|
-
* @returns {Promise<Finding[]>} Structured findings
|
|
371
|
+
* @returns {Promise<Finding[]>} Structured findings, empty when conformant.
|
|
372
372
|
* Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
|
|
373
373
|
* with `emitFindingsText` / `emitFindingsJson` from libutil.
|
|
374
374
|
*/
|
package/src/invariant-kit.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// The invariant
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// a function. Modules never import this file
|
|
6
|
-
// kit per run and passes it in
|
|
7
|
-
// the `runtime` bag
|
|
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
|
-
//
|
|
10
|
-
// (`runtime.fsSync`,
|
|
11
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
166
|
-
*
|
|
167
|
-
* own directory, for co-located config), and `runtime` (the
|
|
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("
|
|
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? }`)
|
|
256
|
-
* call defaults
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
347
|
-
*
|
|
348
|
-
* trimmed)
|
|
349
|
-
* `equal(restated, expected, key)`. The module supplies the domain
|
|
350
|
-
* (how `expected
|
|
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
|
-
*
|
|
353
|
-
* other colon-bearing values that ripgrep's single-file output
|
|
354
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
455
|
-
* string
|
|
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
|
|
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
|
|
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
|
-
//
|
|
10
|
-
// bundle their bare imports
|
|
11
|
-
// to resolve them from
|
|
12
|
-
// third-party packages libinvariant already bundles as virtual
|
|
13
|
-
// compiled rule module resolves them to the embedded copies.
|
|
14
|
-
// this is a no-op
|
|
15
|
-
// imports a package beyond this set must run
|
|
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
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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}
|
|
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
|
|
48
|
-
//
|
|
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
|
|
68
|
-
// declarative checks `runRules` applies over them. The repository
|
|
69
|
-
// rule modules
|
|
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
|
|
93
|
-
*
|
|
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
|
|
97
|
-
*
|
|
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
|
|
127
|
-
* subjects
|
|
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
|
|
132
|
-
*
|
|
133
|
-
* @returns {Promise<object[]>} Structured findings
|
|
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
|
|
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 []
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
500
|
-
* `emitFindingsJson` from libutil
|
|
501
|
-
* are out of date
|
|
502
|
-
* that
|
|
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");
|