@rungs/cli 0.1.3 → 0.3.0

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.
Files changed (40) hide show
  1. package/README.md +61 -11
  2. package/dist/cli.js +1250 -129
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +13 -0
  5. package/modules/adr/gates/adr.toml +14 -2
  6. package/modules/adr/module.toml +19 -1
  7. package/modules/audit/module.toml +1 -0
  8. package/modules/backlog/gates/ids.toml +33 -0
  9. package/modules/backlog/module.toml +61 -1
  10. package/modules/ci/files/{{workflow_path}} +9 -1
  11. package/modules/ci/module.toml +2 -1
  12. package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
  13. package/modules/concurrency/module.toml +3 -1
  14. package/modules/design-sync/module.toml +2 -0
  15. package/modules/doc-authority/module.toml +4 -0
  16. package/modules/findings/module.toml +3 -0
  17. package/modules/gates/gates/structural.toml +61 -17
  18. package/modules/gates/module.toml +5 -0
  19. package/modules/instructions/module.toml +4 -0
  20. package/modules/release/gates/release.toml +72 -4
  21. package/modules/release/module.toml +16 -1
  22. package/modules/release/skills/cut-release/SKILL.md +8 -1
  23. package/modules/session/module.toml +4 -2
  24. package/modules/skills/module.toml +3 -0
  25. package/modules/specs/module.toml +4 -0
  26. package/modules/workflows/module.toml +2 -0
  27. package/package.json +1 -1
  28. package/src/add.ts +64 -2
  29. package/src/backlog.ts +197 -0
  30. package/src/check.ts +56 -6
  31. package/src/cli.ts +406 -27
  32. package/src/concurrency.ts +412 -0
  33. package/src/engines.ts +261 -13
  34. package/src/engines2.ts +89 -4
  35. package/src/engines3.ts +147 -0
  36. package/src/explain.ts +189 -0
  37. package/src/lifecycle.ts +90 -3
  38. package/src/manifest.ts +13 -1
  39. package/src/selftest.ts +237 -0
  40. package/src/types.ts +34 -0
@@ -0,0 +1,237 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { ENGINES, type Finding } from './engines.ts';
5
+
6
+ /**
7
+ * Execute a gate's `[[self_test]]` fixtures instead of only checking they exist.
8
+ *
9
+ * F-006 / WI-045. `gateMeta` confirmed that a `pass` block and a `fail` block
10
+ * were declared and never ran either, so every fixture in the repo was
11
+ * documentation shaped like a test. `gates-links-resolve`'s
12
+ * `See [the plan](./does-not-exist.md).` had never been executed — and had it
13
+ * been, it would have caught F-005 months before a person found it by hand.
14
+ *
15
+ * That is `gate-self-test`'s own argument turned on itself: *a gate whose rules
16
+ * are all currently satisfied is indistinguishable from a gate that matches
17
+ * nothing*, and a self-test that never runs is indistinguishable from one that
18
+ * would fail.
19
+ *
20
+ * **What this does not do is as important as what it does.** A fixture whose
21
+ * shape has no builder is reported **unrun, by name**. It is never counted as a
22
+ * pass, because a suite that reports green while three quarters of it never
23
+ * executed is this finding again, one level up.
24
+ */
25
+
26
+ export interface SelfTestResult {
27
+ gate: string;
28
+ expect: 'pass' | 'fail';
29
+ outcome: 'ok' | 'mismatch' | 'unrun';
30
+ detail?: string;
31
+ }
32
+
33
+ /**
34
+ * A concrete path the table's `scan` would match, for writing a fixture into.
35
+ *
36
+ * The table may be an **array** — `[[frontmatter_schema]]` declares several — in
37
+ * which case `t.file`/`t.scan` are undefined and the first "scan pattern" was
38
+ * an entire schema object. That silently produced a nonsense path, the engine
39
+ * saw no file, and the fixture was reported as a gate failure (F-018).
40
+ */
41
+ function targetPath(table: any): string {
42
+ const t = Array.isArray(table) ? table[0] ?? {} : table ?? {};
43
+ const pattern: string = t.file ?? [t.scan ?? []].flat()[0] ?? '**/*.md';
44
+ if (!pattern.includes('*')) return pattern;
45
+ return pattern
46
+ .replace(/\*\*\//g, 'probe/')
47
+ .replace(/\/\*\*/g, '/probe')
48
+ .replace(/\*/g, 'probe')
49
+ .replace(/probe\.probe$/, 'probe.md');
50
+ }
51
+
52
+ /**
53
+ * Build the fixture's repo state, or return null when its shape has no builder.
54
+ *
55
+ * The declarative keys are deliberately handled generically — `frontmatter`,
56
+ * `sections`, `opening` and `body` are all "a markdown file with this in it",
57
+ * and writing one builder per key would be the same per-shape sprawl that left
58
+ * 85 of 114 fixtures unrunnable in the first place.
59
+ */
60
+ function build(root: string, table: any, fx: any, input?: string): string[] | null {
61
+ const write = (rel: string, body: string) => {
62
+ const full = join(root, rel);
63
+ mkdirSync(dirname(full), { recursive: true });
64
+ writeFileSync(full, body);
65
+ return rel;
66
+ };
67
+
68
+ if (typeof input === 'string') return [write(targetPath(table), `${input}\n`)];
69
+ if (!fx || typeof fx !== 'object') return null;
70
+
71
+ // A set of manifests and the version each states — the computed-claim shapes.
72
+ // `{ "package.json": "1.2.0", "site/package.json": "1.1.0" }`.
73
+ if (fx.packages && typeof fx.packages === 'object') {
74
+ return Object.entries(fx.packages).map(([rel, version]) =>
75
+ write(rel, JSON.stringify({ name: rel.replace(/\W/g, '-'), version })),
76
+ );
77
+ }
78
+
79
+ // Named files in a parameterised directory, plus the version they are judged
80
+ // against — the changelog shapes. `dir` is stated by the fixture rather than
81
+ // assumed here, because the self-test sees the module's *raw* table and a
82
+ // `{{changelog_dir}}` glob cannot match anything on disk; see `deparam`.
83
+ if (Array.isArray(fx.fragments) && typeof fx.version === 'string') {
84
+ const dir = fx.dir ?? 'changelog.d';
85
+ // Forward slashes, not `join`: the returned paths are matched against the
86
+ // spec's globs, and on Windows `join` yields `changelog.d\0.1.1.md`, which
87
+ // `changelog.d/*.md` does not match. The gate then reports "did not fire"
88
+ // about the harness rather than the fixture.
89
+ const written = fx.fragments.map((n: string) => write(`${dir}/${n}`, `# ${n}\n`));
90
+ written.push(write('package.json', JSON.stringify({ version: fx.version })));
91
+ return written;
92
+ }
93
+
94
+ // N files matching the table's scan — the population shapes.
95
+ if (typeof fx.matching_files === 'number') {
96
+ const base = fx.location ?? dirname(targetPath(table));
97
+ const marker = fx.exempt ? `<!-- ${fx.exempt} -->\n` : '';
98
+ return Array.from({ length: fx.matching_files }, (_, i) =>
99
+ write(join(base === '.' ? '' : base, `probe-${i}.md`), `${marker}# probe ${i}\n`),
100
+ );
101
+ }
102
+
103
+ // A single markdown file described declaratively.
104
+ const contentKeys = ['frontmatter', 'sections', 'opening', 'body', 'row', 'table'];
105
+ if (contentKeys.some((k) => k in fx)) {
106
+ const parts: string[] = [];
107
+ // `table = "Closed"` names the heading the row belongs under. A register
108
+ // engine finds rows by section, so a row written without its heading is in
109
+ // no table at all — which read as "the gate did not fire" (F-018).
110
+ if (fx.table) parts.push(`## ${fx.table}`, '');
111
+ if (fx.frontmatter && typeof fx.frontmatter === 'object') {
112
+ parts.push('---');
113
+ for (const [k, v] of Object.entries(fx.frontmatter)) parts.push(`${k}: ${v}`);
114
+ parts.push('---', '');
115
+ }
116
+ if (fx.opening) parts.push(String(fx.opening), '');
117
+ for (const s of fx.sections ?? []) parts.push(`## ${s}`, '', 'text', '');
118
+ if (fx.row && typeof fx.row === 'object') {
119
+ const cols = Object.keys(fx.row);
120
+ parts.push(`| ${cols.join(' | ')} |`, `| ${cols.map(() => '---').join(' | ')} |`,
121
+ `| ${cols.map((c) => (fx.row as any)[c]).join(' | ')} |`, '');
122
+ }
123
+ if (fx.body) parts.push(String(fx.body), '');
124
+ return [write(fx.file ?? targetPath(table), `${parts.join('\n')}\n`)];
125
+ }
126
+
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Engines whose verdict depends **only on the content of the file the fixture
132
+ * describes**, so a fixture can be executed faithfully in an empty directory.
133
+ *
134
+ * The rest need context the fixture does not carry, and running them anyway
135
+ * produces confident nonsense. `gates-links-resolve`'s `pass` fixture is
136
+ * `See [this table](./structural.toml).` — it asserts that a link *which
137
+ * resolves* passes, and in a temp directory it does not resolve, so the runner
138
+ * would report the gate broken. Creating the target to make it pass would be
139
+ * assuming the answer, and only for `expect = "pass"` blocks, which is worse.
140
+ *
141
+ * Measured 2026-08-16: without this restriction the runner reported 17
142
+ * "failures", and the ones inspected were all its own artifacts. A test harness
143
+ * that cries wolf gets deleted faster than the gate it was checking.
144
+ *
145
+ * This is the same shape as `applicability` in ADR-0007, one level down: ask
146
+ * whether the check can legitimately run before running it.
147
+ */
148
+ const CONTEXT_FREE: ReadonlySet<string> = new Set([
149
+ 'frontmatter-schema',
150
+ 'sections',
151
+ 'file-budget',
152
+ 'register-schema',
153
+ 'file-population',
154
+ 'changelog-freshness',
155
+ 'computed-claim',
156
+ ]);
157
+
158
+ /**
159
+ * Replace `{{param}}` segments in a spec's globs with the literal the fixture
160
+ * stands in for.
161
+ *
162
+ * The runner reads the **module's** gate table, where paths are still written as
163
+ * parameters — `{{changelog_dir}}/*.md`. Nothing substitutes them here, because
164
+ * there is no installed repo to take values from. So a fixture that exercises a
165
+ * parameterised path has to say what it is standing in for, and the spec has to
166
+ * be told the same thing, or the glob matches a file the builder just wrote and
167
+ * the gate reports "did not fire" about its own harness.
168
+ */
169
+ function deparam<T>(spec: T, dir: string): T {
170
+ const walk = (v: any): any =>
171
+ typeof v === 'string' ? v.replace(/\{\{[^}]+\}\}/g, dir)
172
+ : Array.isArray(v) ? v.map(walk)
173
+ : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
174
+ : v;
175
+ return walk(spec);
176
+ }
177
+
178
+ export function runSelfTests(
179
+ gateId: string,
180
+ engine: string,
181
+ table: any,
182
+ blocks: { expect: string; input?: string; fixture?: any }[],
183
+ ): SelfTestResult[] {
184
+ const out: SelfTestResult[] = [];
185
+ for (const b of blocks) {
186
+ const expect = b.expect === 'fail' ? 'fail' : 'pass';
187
+ if (!CONTEXT_FREE.has(engine)) {
188
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `${engine} fixtures need context the fixture does not carry` });
189
+ continue;
190
+ }
191
+ if (!(engine in ENGINES)) {
192
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `engine '${engine}' not implemented` });
193
+ continue;
194
+ }
195
+ const root = mkdtempSync(join(tmpdir(), 'rungs-selftest-'));
196
+ try {
197
+ const files = build(root, table, b.fixture, b.input);
198
+ // A fixture states an opt-in with `opted_in`; the engine reads it from the
199
+ // spec as `extensions_opted_in`. Without the bridge, the `pass` fixture for
200
+ // an opted-in extension fired `non-spec key` — the harness asserting the
201
+ // opposite of what the fixture said (F-018).
202
+ let spec = b.fixture?.opted_in
203
+ ? (Array.isArray(table) ? table.map((s: any) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in })
204
+ : table;
205
+ // Same bridge, for paths: a fixture that names a parameterised directory
206
+ // has to hand the spec the same literal it wrote the files into.
207
+ if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? 'changelog.d');
208
+ // And for `exclude`, which is the thing under test in half these fixtures:
209
+ // the table ships it empty by default, so a fixture proving exclusion works
210
+ // has to set it, exactly as a repo would.
211
+ if (Array.isArray(b.fixture?.exclude)) {
212
+ const ex = b.fixture.exclude;
213
+ spec = Array.isArray(spec) ? spec.map((s: any) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
214
+ }
215
+ if (!files) {
216
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
217
+ continue;
218
+ }
219
+ let findings: Finding[];
220
+ try {
221
+ findings = ENGINES[engine](spec, root, files).findings;
222
+ } catch (e: any) {
223
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `engine threw: ${e.message}`.slice(0, 90) });
224
+ continue;
225
+ }
226
+ const fired = findings.length > 0;
227
+ out.push(
228
+ fired === (expect === 'fail')
229
+ ? { gate: gateId, expect, outcome: 'ok' }
230
+ : { gate: gateId, expect, outcome: 'mismatch', detail: `expected ${expect}, ${fired ? `fired: ${findings[0].message}`.slice(0, 80) : 'did not fire'}` },
231
+ );
232
+ } finally {
233
+ rmSync(root, { recursive: true, force: true });
234
+ }
235
+ }
236
+ return out;
237
+ }
package/src/types.ts CHANGED
@@ -15,10 +15,35 @@ export interface ParamSpec {
15
15
  consumed_by?: string;
16
16
  }
17
17
 
18
+ /**
19
+ * Whether a detector can legitimately interpret a repo that did not adopt our
20
+ * conventions — asked *before* "did the condition fire" (WI-052).
21
+ *
22
+ * This lived as two hard-coded sets of engine names inside `explain.ts`, which
23
+ * meant a new gate inherited an applicability nobody chose for it and could not
24
+ * see. The three cases are the ones measurement produced, not a taxonomy:
25
+ *
26
+ * - `repo-content` measures the repo's own content. A broken link is a broken
27
+ * link in anybody's methodology, and so is a 1,358-line file.
28
+ * - `our-artifacts` checks something rungs wrote. On a repo that never installed
29
+ * it the artifact cannot exist, so the finding is guaranteed by the repo's
30
+ * *state* rather than its *content* — `adr-index-current` reporting a missing
31
+ * `adr-index` block against hexguard's perfectly healthy decision index.
32
+ * - `our-schema` reads their file against a shape we defined.
33
+ * `specs-status-evidence` produced 70 findings on hexguard-templates whose
34
+ * spec register is fine and simply has its own columns.
35
+ *
36
+ * Only `repo-content` runs on a repo that is not ours. There is deliberately no
37
+ * default: an undeclared gate is reported, never quietly assumed safe — the same
38
+ * rule `enforcement-declaration` applies to `gated` / `review-only`.
39
+ */
40
+ export type Applicability = 'repo-content' | 'our-artifacts' | 'our-schema';
41
+
18
42
  export interface GateSpec {
19
43
  id: string;
20
44
  kind: 'declared' | 'command';
21
45
  engine?: string;
46
+ applicability?: Applicability;
22
47
  table?: string;
23
48
  command?: string;
24
49
  tier?: string;
@@ -79,6 +104,15 @@ export interface Manifest {
79
104
  params: Record<string, ParamSpec>;
80
105
  gates: GateSpec[];
81
106
  detect: DetectSpec;
107
+ /**
108
+ * Per skill, the harness extensions this module opted into — `[skills.<name>]`
109
+ * in `module.toml`.
110
+ *
111
+ * Was declared in manifests and documented in `modules/README.md` while being
112
+ * parsed by nothing (F-019), so the opt-in that is meant to attach a
113
+ * portability cost to a decision attached to nothing at all.
114
+ */
115
+ skills?: Record<string, { extensions?: Record<string, unknown>; extension_note?: string }>;
82
116
  provenance: Provenance;
83
117
  threshold?: { metric: string; minimum: number; confirm?: boolean };
84
118
  /** Absolute path to the module directory. */