@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 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 and JTBD
6
- invariants across the repo.
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 two subcommands implement the contract described in
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 { checkInstructions, checkJtbd } from "../src/index.js";
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.8",
4
- "description": "Co-Aligned architecture checks — enforce instruction-layer length caps and JTBD invariants across the repo.",
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.0.0"
52
+ "prettier": "^3.8.4"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@forwardimpact/libmock": "^0.1.0"
package/src/index.js CHANGED
@@ -1,2 +1,9 @@
1
1
  export { checkInstructions } from "./instructions.js";
2
+ export {
3
+ checkInvariants,
4
+ findInvariantsRoot,
5
+ INVARIANTS_DIR,
6
+ loadRuleModules,
7
+ runRuleModules,
8
+ } from "./invariants.js";
2
9
  export { checkJtbd } from "./jtbd.js";
@@ -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
+ }