@forwardimpact/libcoaligned 0.1.8 → 0.1.10
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 +34 -3
- package/bin/coaligned.js +58 -1
- package/package.json +4 -3
- package/src/index.js +7 -0
- package/src/invariants.js +130 -0
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- BEGIN:description — Do not edit. Generated from package.json. -->
|
|
4
4
|
|
|
5
|
-
Co-Aligned architecture checks — enforce instruction-layer length caps
|
|
6
|
-
invariants
|
|
5
|
+
Co-Aligned architecture checks — enforce instruction-layer length caps, JTBD
|
|
6
|
+
invariants, and the repo's own declarative invariant rule modules.
|
|
7
7
|
|
|
8
8
|
<!-- END:description -->
|
|
9
9
|
|
|
@@ -14,9 +14,10 @@ npx coaligned # run every check (instructions + jtbd)
|
|
|
14
14
|
npx coaligned instructions # enforce L1–L7 length and checklist caps
|
|
15
15
|
npx coaligned jtbd # validate JTBD entries against package.json
|
|
16
16
|
npx coaligned jtbd --fix # regenerate catalog and job blocks in place
|
|
17
|
+
npx coaligned invariants # run the repo's own rule modules
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
The
|
|
20
|
+
The `instructions` and `jtbd` subcommands implement the contract described in
|
|
20
21
|
[COALIGNED.md](https://github.com/forwardimpact/monorepo/blob/main/COALIGNED.md):
|
|
21
22
|
|
|
22
23
|
- `instructions` — every layer (L1 CLAUDE.md, L2 CONTRIBUTING.md / JTBD.md,
|
|
@@ -26,3 +27,33 @@ The two subcommands implement the contract described in
|
|
|
26
27
|
- `jtbd` — each `package.json .jobs` entry is validated against the JTBD
|
|
27
28
|
schema; with `--fix`, marker-delimited blocks in `<dir>/README.md`,
|
|
28
29
|
`<dir>/<pkg>/README.md`, and root `JTBD.md` are regenerated.
|
|
30
|
+
|
|
31
|
+
## Invariants
|
|
32
|
+
|
|
33
|
+
`coaligned invariants` is a generic host for a repository's own invariant
|
|
34
|
+
checks. It resolves the project root (from any subdirectory), loads every
|
|
35
|
+
`*.rules.mjs` module under `.coaligned/invariants/`, and runs each module's
|
|
36
|
+
declarative rule catalogue through the shared rules engine. The policies stay
|
|
37
|
+
in the repository; the CLI ships only the engine.
|
|
38
|
+
|
|
39
|
+
A rule module's default export is:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
export default {
|
|
43
|
+
name: "ambient-deps",
|
|
44
|
+
// Walk the repo and return plain subjects per scope (plus optional
|
|
45
|
+
// shared ctx the rules read).
|
|
46
|
+
build: async ({ root, runtime }) => ({
|
|
47
|
+
subjects: { "src-file": [{ path, smells }] },
|
|
48
|
+
ctx: { deny },
|
|
49
|
+
}),
|
|
50
|
+
// Declarative rules over those subjects.
|
|
51
|
+
rules: [{ id, scope, severity, when, check, message, hint }],
|
|
52
|
+
// Optional: text for `coaligned invariants --seed <name>` — e.g. a
|
|
53
|
+
// regenerated grandfather deny-list.
|
|
54
|
+
seed: async ({ root, runtime }) => "…",
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Findings render in the same ESLint-style format as the other subcommands
|
|
59
|
+
(`--json` for machine output); any finding fails the run.
|
package/bin/coaligned.js
CHANGED
|
@@ -5,7 +5,14 @@ import "@forwardimpact/libpreflight/node22";
|
|
|
5
5
|
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
6
6
|
import { createCli } from "@forwardimpact/libcli";
|
|
7
7
|
import { emitFindingsJson, emitFindingsText } from "@forwardimpact/libutil";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
checkInstructions,
|
|
10
|
+
checkJtbd,
|
|
11
|
+
findInvariantsRoot,
|
|
12
|
+
INVARIANTS_DIR,
|
|
13
|
+
loadRuleModules,
|
|
14
|
+
runRuleModules,
|
|
15
|
+
} from "../src/index.js";
|
|
9
16
|
|
|
10
17
|
const runtime = createDefaultRuntime();
|
|
11
18
|
|
|
@@ -21,6 +28,23 @@ const definition = {
|
|
|
21
28
|
handler: instructionsHandler,
|
|
22
29
|
examples: ["coaligned instructions"],
|
|
23
30
|
},
|
|
31
|
+
{
|
|
32
|
+
name: "invariants",
|
|
33
|
+
args: [],
|
|
34
|
+
description: `Run the repository's invariant rule modules from ${INVARIANTS_DIR}/`,
|
|
35
|
+
options: {
|
|
36
|
+
seed: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description:
|
|
39
|
+
"Print the named module's seed output (e.g. a refreshed deny-list) instead of checking",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
handler: invariantsHandler,
|
|
43
|
+
examples: [
|
|
44
|
+
"coaligned invariants",
|
|
45
|
+
"coaligned invariants --seed ambient-deps",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
24
48
|
{
|
|
25
49
|
name: "jtbd",
|
|
26
50
|
args: [],
|
|
@@ -92,6 +116,39 @@ async function instructionsHandler(ctx) {
|
|
|
92
116
|
return runInstructions(ctx.data.root, !!ctx.options.json, rt);
|
|
93
117
|
}
|
|
94
118
|
|
|
119
|
+
// Unlike instructions/jtbd, invariants resolves the project root through the
|
|
120
|
+
// finder so the rule modules are picked up from `<root>/.coaligned/invariants`
|
|
121
|
+
// no matter which subdirectory the command runs from.
|
|
122
|
+
async function invariantsHandler(ctx) {
|
|
123
|
+
const rt = ctx.deps.runtime;
|
|
124
|
+
const root = findInvariantsRoot(rt);
|
|
125
|
+
const modules = await loadRuleModules({ root, runtime: rt });
|
|
126
|
+
|
|
127
|
+
if (ctx.options.seed) {
|
|
128
|
+
const mod = modules.find((m) => m.name === ctx.options.seed);
|
|
129
|
+
if (!mod) {
|
|
130
|
+
cli.error(`no rule module named "${ctx.options.seed}"`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
if (typeof mod.seed !== "function") {
|
|
134
|
+
cli.error(`rule module "${mod.name}" has no seed output`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
rt.proc.stdout.write(await mod.seed({ root, runtime: rt }));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const findings = await runRuleModules(modules, { root, runtime: rt });
|
|
142
|
+
writeFindings(
|
|
143
|
+
findings,
|
|
144
|
+
"coaligned invariants passed",
|
|
145
|
+
!!ctx.options.json,
|
|
146
|
+
root,
|
|
147
|
+
rt,
|
|
148
|
+
);
|
|
149
|
+
return findings.length > 0 ? 1 : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
95
152
|
async function jtbdHandler(ctx) {
|
|
96
153
|
const rt = ctx.deps.runtime;
|
|
97
154
|
return runJtbd(ctx.data.root, !!ctx.options.fix, !!ctx.options.json, rt);
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libcoaligned",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Co-Aligned architecture checks — enforce instruction-layer length caps
|
|
3
|
+
"version": "0.1.10",
|
|
4
|
+
"description": "Co-Aligned architecture checks — enforce instruction-layer length caps, JTBD invariants, and the repo's own declarative invariant rule modules.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coaligned",
|
|
7
7
|
"instructions",
|
|
8
8
|
"jtbd",
|
|
9
|
+
"invariants",
|
|
9
10
|
"checklist",
|
|
10
11
|
"agent"
|
|
11
12
|
],
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"@forwardimpact/libcli": "^0.1.9",
|
|
49
50
|
"@forwardimpact/libpreflight": "^0.1.0",
|
|
50
51
|
"@forwardimpact/libutil": "*",
|
|
51
|
-
"prettier": "^3.
|
|
52
|
+
"prettier": "^3.8.4"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@forwardimpact/libmock": "^0.1.0"
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
4
|
+
|
|
5
|
+
// The conventional rules location, relative to the project root.
|
|
6
|
+
export const INVARIANTS_DIR = ".coaligned/invariants";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the root whose `.coaligned/invariants/` applies to the working
|
|
10
|
+
* directory. The nearest `package.json` is not enough — inside a monorepo
|
|
11
|
+
* every workspace package has one — so search upward for the rules directory
|
|
12
|
+
* itself, falling back to the nearest project root so the loader's error
|
|
13
|
+
* names the expected location.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
|
|
16
|
+
* @returns {string} Project root directory path.
|
|
17
|
+
*/
|
|
18
|
+
export function findInvariantsRoot(runtime) {
|
|
19
|
+
const found = runtime.finder.findUpward(
|
|
20
|
+
runtime.proc.cwd(),
|
|
21
|
+
INVARIANTS_DIR,
|
|
22
|
+
8,
|
|
23
|
+
);
|
|
24
|
+
return found ? resolve(found, "../..") : runtime.finder.findProjectRoot();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Generic host for repo-local invariant rule modules. A rule module is a
|
|
28
|
+
// `*.rules.mjs` (or `*.rules.js`) file whose default export is:
|
|
29
|
+
//
|
|
30
|
+
// {
|
|
31
|
+
// name: "ambient-deps",
|
|
32
|
+
// build: async ({ root, runtime }) =>
|
|
33
|
+
// ({ subjects: { "<scope>": [subject, …] }, ctx? }),
|
|
34
|
+
// rules: [{ id, scope, severity, when?, check, message, hint? }, …],
|
|
35
|
+
// seed?: async ({ root, runtime }) => "text", // e.g. a refreshed deny-list
|
|
36
|
+
// }
|
|
37
|
+
//
|
|
38
|
+
// `build` walks the repo and returns plain subjects per scope; `rules` are the
|
|
39
|
+
// declarative checks `runRules` applies over them. The repository owns its
|
|
40
|
+
// rule modules — this host only discovers, loads, and runs them, so the
|
|
41
|
+
// policies themselves never ship with the CLI.
|
|
42
|
+
|
|
43
|
+
function assertModuleShape(mod, fileName) {
|
|
44
|
+
const ok =
|
|
45
|
+
mod &&
|
|
46
|
+
typeof mod.name === "string" &&
|
|
47
|
+
typeof mod.build === "function" &&
|
|
48
|
+
Array.isArray(mod.rules);
|
|
49
|
+
if (!ok) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${fileName}: default export must be { name, build, rules }`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Discover and import every rule module under `rulesDir` (sorted by file
|
|
58
|
+
* name for a stable run order).
|
|
59
|
+
*
|
|
60
|
+
* @param {{ root: string, rulesDir: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
|
|
61
|
+
* @returns {Promise<object[]>} The modules' default exports.
|
|
62
|
+
*/
|
|
63
|
+
export async function loadRuleModules({
|
|
64
|
+
root,
|
|
65
|
+
rulesDir = INVARIANTS_DIR,
|
|
66
|
+
runtime,
|
|
67
|
+
}) {
|
|
68
|
+
const dir = resolve(root, rulesDir);
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = await runtime.fs.readdir(dir, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`rules directory not found: ${dir}`);
|
|
74
|
+
}
|
|
75
|
+
const names = entries
|
|
76
|
+
.filter((e) => e.isFile() && /\.rules\.m?js$/.test(e.name))
|
|
77
|
+
.map((e) => e.name)
|
|
78
|
+
.sort();
|
|
79
|
+
if (names.length === 0) {
|
|
80
|
+
throw new Error(`no *.rules.mjs modules found in ${dir}`);
|
|
81
|
+
}
|
|
82
|
+
const modules = [];
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const mod = (await import(pathToFileURL(resolve(dir, name)).href)).default;
|
|
85
|
+
assertModuleShape(mod, name);
|
|
86
|
+
modules.push(mod);
|
|
87
|
+
}
|
|
88
|
+
return modules;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run already-loaded rule modules: build each module's subjects, then apply
|
|
93
|
+
* its rule catalogue through the shared rules engine.
|
|
94
|
+
*
|
|
95
|
+
* @param {object[]} modules - Rule-module default exports.
|
|
96
|
+
* @param {{ root: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
|
|
97
|
+
* @returns {Promise<object[]>} Structured findings; empty when conformant.
|
|
98
|
+
*/
|
|
99
|
+
export async function runRuleModules(modules, { root, runtime }) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
for (const mod of modules) {
|
|
102
|
+
const { subjects, ctx = {} } = await mod.build({ root, runtime });
|
|
103
|
+
findings.push(
|
|
104
|
+
...runRules(
|
|
105
|
+
mod.rules,
|
|
106
|
+
{ ...ctx, subjects },
|
|
107
|
+
{ resolveScope: (key, c) => c.subjects[key] ?? [] },
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load every rule module under `root`/`rulesDir` and run it.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ root: string, rulesDir: string, runtime: import('@forwardimpact/libutil/runtime').Runtime }} options
|
|
118
|
+
* @returns {Promise<object[]>} Structured findings; empty when conformant.
|
|
119
|
+
* Each finding is `{ id, level, path, lineNo?, message, hint? }` for use
|
|
120
|
+
* with `emitFindingsText` / `emitFindingsJson` from libutil.
|
|
121
|
+
*/
|
|
122
|
+
export async function checkInvariants({
|
|
123
|
+
root,
|
|
124
|
+
rulesDir = INVARIANTS_DIR,
|
|
125
|
+
runtime,
|
|
126
|
+
}) {
|
|
127
|
+
if (!runtime) throw new Error("runtime is required");
|
|
128
|
+
const modules = await loadRuleModules({ root, rulesDir, runtime });
|
|
129
|
+
return runRuleModules(modules, { root, runtime });
|
|
130
|
+
}
|