@rungs/cli 0.1.2 → 0.2.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.
package/src/explain.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { ENGINES, type Finding } from './engines.ts';
2
+ import { loadTable, tableKey } from './check.ts';
3
+ import type { DetectResult, Manifest } from './types.ts';
4
+
5
+ /**
6
+ * `doctor` answers a *presence* question — which of our modules does this repo
7
+ * already have an equivalent of. The question a repo actually arrives with is a
8
+ * *defect* question: which of my agent rules say MUST and have nothing checking
9
+ * them, how many near-identical CI workflows do I have, which topics have two
10
+ * documents claiming authority.
11
+ *
12
+ * Those detectors were already written. They ship as gates inside modules and
13
+ * ran only after installation, over rungs-managed content — so the analysis was
14
+ * gated behind installing the thing the analysis exists to justify, which is
15
+ * backwards for a tool whose primary case is retrofit (WI-038).
16
+ *
17
+ * Nothing here is new detection. It is the same `ENGINES` table the runner
18
+ * uses, over a registry synthesized in memory from the module manifests rather
19
+ * than read from `.ai/gates.toml`, which an unmanaged repo does not have.
20
+ */
21
+
22
+ export interface DetectorFinding {
23
+ module: string;
24
+ gate: string;
25
+ /** The extracted incident behind the gate, from the manifest. */
26
+ why?: string;
27
+ findings: Finding[];
28
+ examined: number;
29
+ }
30
+
31
+ export interface ExplainResult {
32
+ reported: DetectorFinding[];
33
+ /** Gates skipped, by reason — printed, because a silent skip reads as a pass. */
34
+ skipped: { command: number; unimplemented: string[]; undeclared: string[]; errored: { gate: string; message: string }[] };
35
+ /** Modules whose detectors ran at all. */
36
+ scope: string[];
37
+ }
38
+
39
+ type EngineTable = Record<string, (t: any, root: string, files: string[]) => { findings: Finding[]; examined: number }>;
40
+
41
+ /**
42
+ * A `command` gate runs a shell command the *repo* owns. On a repo that never
43
+ * installed rungs there is no registry to own one, but a module can still
44
+ * declare one — and executing an arbitrary command against somebody else's
45
+ * checkout because they typed a read-only-sounding flag is not a thing this
46
+ * tool gets to do. Skipped, and counted, never run.
47
+ */
48
+ const isRunnable = (g: Manifest['gates'][number]) => g.kind !== 'command' && !g.trigger && !!g.engine;
49
+
50
+ /**
51
+ * Which modules' detectors are allowed to run.
52
+ *
53
+ * **Only what the repo already has.** ADR-0004 biased detection signatures
54
+ * toward false negatives; the same bias applies here for a stronger reason —
55
+ * these engines read rungs-shaped inputs, so on a foreign repo a
56
+ * technically-correct finding can still be framed against a convention the repo
57
+ * never adopted. A module the repo has no equivalent of has nothing to check,
58
+ * and running it anyway produces exactly the confident noise that loses an
59
+ * adoption wedge.
60
+ *
61
+ * `paradigm` is excluded too: the repo solves that problem a different way, and
62
+ * measuring their solution against our shape is the same error with a nastier
63
+ * tone.
64
+ */
65
+ export const IN_SCOPE: ReadonlySet<string> = new Set(['theirs', 'ours-current', 'ours-diverged']);
66
+
67
+ /**
68
+ * Which declared applicability may run against a repo that is not ours.
69
+ *
70
+ * This was two hard-coded sets of **engine names** in this file, and the
71
+ * knowledge lived nowhere near the gates it governed: adding a gate on
72
+ * `file-population` silently made it foreign-safe, and adding one on a new
73
+ * engine silently made it not, with nothing at either declaration saying so.
74
+ * It is now a required field on each gate — see `Applicability` in `types.ts`
75
+ * for what the three cases mean and which measurement produced them.
76
+ */
77
+ const FOREIGN_SAFE: ReadonlySet<string> = new Set(['repo-content']);
78
+
79
+ export function explain(
80
+ mods: Manifest[],
81
+ results: DetectResult[],
82
+ repoRoot: string,
83
+ files: string[],
84
+ ): ExplainResult {
85
+ return explainWith(ENGINES, mods, results, repoRoot, files);
86
+ }
87
+
88
+ /**
89
+ * `explain` with the engine table injected, so the scope rules above can be
90
+ * tested without a repo on disk. Those rules are the whole safety argument of
91
+ * this pass; testing them through fifteen real manifests would test the
92
+ * manifests instead.
93
+ */
94
+ export function explainWith(
95
+ engines: EngineTable,
96
+ mods: Manifest[],
97
+ results: DetectResult[],
98
+ repoRoot: string,
99
+ files: string[],
100
+ ): ExplainResult {
101
+ const inScope = results.filter((r) => IN_SCOPE.has(r.state));
102
+ const scope = inScope.map((r) => r.module);
103
+ const stateOf = new Map(inScope.map((r) => [r.module, r.state]));
104
+ const reported: DetectorFinding[] = [];
105
+ const skipped: ExplainResult['skipped'] = { command: 0, unimplemented: [], undeclared: [], errored: [] };
106
+
107
+ for (const name of scope) {
108
+ const mod = mods.find((m) => m.name === name);
109
+ if (!mod) continue;
110
+ const isOurs = stateOf.get(name) !== 'theirs';
111
+
112
+ for (const g of mod.gates) {
113
+ if (!isRunnable(g)) {
114
+ if (g.kind === 'command') skipped.command++;
115
+ continue;
116
+ }
117
+ // No default. A gate that has not said whether it can read a foreign repo
118
+ // does not read one, and is named — silence resolving to "safe" is how the
119
+ // 71 mis-framed findings of WI-038's first version happened.
120
+ if (!isOurs) {
121
+ if (!g.applicability) {
122
+ skipped.undeclared.push(g.id);
123
+ continue;
124
+ }
125
+ if (!FOREIGN_SAFE.has(g.applicability)) continue;
126
+ }
127
+ if (!(g.engine! in engines)) {
128
+ // Same rule as the runner: an engine named and missing is an unknown,
129
+ // and an unknown is never reported as clean.
130
+ skipped.unimplemented.push(g.id);
131
+ continue;
132
+ }
133
+
134
+ const table = loadTable(g.table ? `${mod.name}/${g.table.replace(/^gates\//, '')}` : undefined, repoRoot);
135
+ if (!table) {
136
+ skipped.errored.push({ gate: g.id, message: `table '${g.table ?? '(none)'}' not found` });
137
+ continue;
138
+ }
139
+
140
+ try {
141
+ const key = tableKey(g.engine!);
142
+ let section = table[key] ?? table;
143
+ if (Array.isArray(section) && section.some((s: any) => s?.id)) {
144
+ const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
145
+ if (mine.length) section = mine;
146
+ }
147
+ const r = engines[g.engine!](section, repoRoot, files);
148
+ if (r.findings.length) {
149
+ reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
150
+ }
151
+ } catch (e: any) {
152
+ // An engine that throws on a foreign repo's shapes is a fact about this
153
+ // pass, not about the repo. Reported as ours, not as their finding.
154
+ skipped.errored.push({ gate: g.id, message: e.message });
155
+ }
156
+ }
157
+ }
158
+
159
+ return { reported: collapseDuplicates(reported), skipped, scope };
160
+ }
161
+
162
+ /**
163
+ * Two gate ids that produce the identical finding set are one check reported
164
+ * twice, and on a repo with 112 broken links that is 224 lines of the same
165
+ * thing. `gates-links-resolve` and `gates-paths-exist` currently run the same
166
+ * markdown-link scan — F-007 in `docs/backlog/FINDINGS.md`, open before this
167
+ * pass existed and unchanged by it.
168
+ *
169
+ * Collapsed here rather than fixed there on purpose: the duplication is a defect
170
+ * in the gate set, this is a defect in *reading* the gate set, and a reporting
171
+ * layer that hides a registry problem is how the registry problem survives. The
172
+ * merged row names both ids, so the duplication stays visible to anyone who
173
+ * looks at the output — which is the point.
174
+ */
175
+ export function collapseDuplicates(reported: DetectorFinding[]): DetectorFinding[] {
176
+ const out: DetectorFinding[] = [];
177
+ const seen = new Map<string, DetectorFinding>();
178
+ for (const r of reported) {
179
+ const key = `${r.module} ${r.findings.map((f) => `${f.file ?? ''}|${f.message}`).join('')}`;
180
+ const prior = seen.get(key);
181
+ if (prior) {
182
+ prior.gate = `${prior.gate} + ${r.gate}`;
183
+ continue;
184
+ }
185
+ seen.set(key, r);
186
+ out.push(r);
187
+ }
188
+ return out;
189
+ }
package/src/lifecycle.ts CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { parse } from 'smol-toml';
6
6
  import type { Manifest } from './types.ts';
7
- import { contentHash, emittedFiles } from './add.ts';
7
+ import { contentHash, emittedFiles, registerGates } from './add.ts';
8
8
  import { resolveParams, substitute, type Params } from './substitute.ts';
9
9
  import { loadRegistry } from './check.ts';
10
10
 
@@ -89,18 +89,105 @@ export function applyUpgrade(repoRoot: string, mods: Manifest[], record: Install
89
89
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
90
90
  const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
91
91
  let written = 0;
92
+ // Only files this run rewrote. A diverged file is not in here, which is what
93
+ // keeps its recorded hash — and therefore its protection — intact (F-017).
94
+ const rewritten = new Map<string, Map<string, string>>();
92
95
  for (const item of plan) {
93
96
  const mod = mods.find((m) => m.name === item.module)!;
94
97
  const emitted = emittedFiles(mod, params, skillsDir);
95
98
  for (const f of item.files) {
96
99
  if (f.state !== 'stale' && f.state !== 'missing') continue;
97
100
  const full = join(repoRoot, f.rel);
101
+ const content = emitted.get(f.rel)!;
98
102
  mkdirSync(dirname(full), { recursive: true });
99
- writeFileSync(full, emitted.get(f.rel)!);
103
+ writeFileSync(full, content);
104
+ if (!rewritten.has(mod.name)) rewritten.set(mod.name, new Map());
105
+ rewritten.get(mod.name)!.set(f.rel, contentHash(content));
100
106
  written++;
101
107
  }
102
108
  }
103
- return written;
109
+
110
+ // F-016. Upgrading rewrote a module's **files** and never its **gates**, so a
111
+ // module version that added, removed or renamed one left the registry on the
112
+ // old block and told the user the upgrade succeeded. Reproduced 2026-08-16
113
+ // against a scratch consumer: `session` 1.1.0 → 1.2.0 with a new gate, and
114
+ // `.ai/gates.toml` kept `rungs:begin session@1.1.0` and 20 entries.
115
+ //
116
+ // Registration is by whole merge block, so this fixes removal too — a gate
117
+ // dropped from a manifest leaves the registry with the block that replaces it.
118
+ // Idempotent, and cheap enough to run for every module in the plan rather than
119
+ // only the ones whose files happened to be stale.
120
+ const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)!).filter(Boolean);
121
+ const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
122
+
123
+ const recorded = updateRecordAfterUpgrade(
124
+ repoRoot,
125
+ upgraded.map((m) => ({ module: m.name, version: m.version, hashes: rewritten.get(m.name) ?? new Map() })),
126
+ );
127
+
128
+ return { written, gates: gateActions.length, recorded };
129
+ }
130
+
131
+ /**
132
+ * Update `.ai/rungs.toml` in place after an upgrade: the version each module
133
+ * moved to, and a new hash for each file this run actually rewrote.
134
+ *
135
+ * **Surgical, and text-level, on purpose.** F-017: `upgrade` left the record
136
+ * naming the old version, so a repo on 1.2.0 described itself to its owner as
137
+ * 1.1.0 and `planUpgrade` offered the same move forever. The obvious fix —
138
+ * calling `writeInstallRecord` — is worse than the bug: it re-derives the whole
139
+ * record and hashes **every emitted file that exists**, which would stamp our
140
+ * hash onto a file the user had diverged. That file would then match its record
141
+ * and be silently reclassified from `diverged` to `current`, so the next upgrade
142
+ * would overwrite the edit rungs promises never to touch.
143
+ *
144
+ * So: only the lines that must change, and only for files we wrote. Everything
145
+ * else — the header comment, kept-file lists, and the hash of every file we did
146
+ * not touch — is left exactly as it was.
147
+ */
148
+ export function updateRecordAfterUpgrade(
149
+ repoRoot: string,
150
+ updates: { module: string; version: string; hashes: Map<string, string> }[],
151
+ ): number {
152
+ const path = join(repoRoot, '.ai', 'rungs.toml');
153
+ if (!existsSync(path) || !updates.length) return 0;
154
+
155
+ const lines = readFileSync(path, 'utf8').split('\n');
156
+ const byModule = new Map(updates.map((u) => [u.module, u]));
157
+ let changed = 0;
158
+ let current: { module: string; hashes: boolean } | null = null;
159
+
160
+ const out: string[] = [];
161
+ for (const line of lines) {
162
+ const header = /^\[modules\.([^\].]+)(\.[^\]]+)?\]/.exec(line);
163
+ if (header) {
164
+ current = byModule.has(header[1]) ? { module: header[1], hashes: header[2] === '.hashes' } : null;
165
+ out.push(line);
166
+ continue;
167
+ }
168
+
169
+ if (current && !current.hashes && /^version\s*=/.test(line)) {
170
+ const next = `version = "${byModule.get(current.module)!.version}"`;
171
+ if (next !== line) changed++;
172
+ out.push(next);
173
+ continue;
174
+ }
175
+
176
+ if (current?.hashes) {
177
+ const entry = /^"([^"]+)"\s*=/.exec(line);
178
+ const replacement = entry && byModule.get(current.module)!.hashes.get(entry[1]);
179
+ if (replacement) {
180
+ out.push(`"${entry[1]}" = "${replacement}"`);
181
+ changed++;
182
+ continue;
183
+ }
184
+ }
185
+
186
+ out.push(line);
187
+ }
188
+
189
+ writeFileSync(path, out.join('\n'));
190
+ return changed;
104
191
  }
105
192
 
106
193
  function paramsFrom(record: InstallRecord): Params {
package/src/manifest.ts CHANGED
@@ -21,6 +21,7 @@ export function loadManifest(dir: string): Manifest {
21
21
  params: (raw.params ?? {}) as Record<string, ParamSpec>,
22
22
  gates: raw.gates ?? [],
23
23
  detect: raw.detect ?? {},
24
+ skills: raw.skills ?? {},
24
25
  provenance: raw.provenance,
25
26
  threshold: raw.threshold,
26
27
  dir,
@@ -61,7 +62,7 @@ export function usedParams(dir: string): Set<string> {
61
62
 
62
63
  export interface ManifestIssue {
63
64
  module: string;
64
- kind: 'dead-param' | 'undeclared-param' | 'dep-missing' | 'gate-no-table' | 'gate-no-why';
65
+ kind: 'dead-param' | 'undeclared-param' | 'dep-missing' | 'gate-no-table' | 'gate-no-why' | 'gate-no-applicability';
65
66
  detail: string;
66
67
  }
67
68
 
@@ -102,6 +103,17 @@ export function auditModules(mods: Manifest[]): ManifestIssue[] {
102
103
  if (!g.why?.trim()) {
103
104
  issues.push({ module: mod.name, kind: 'gate-no-why', detail: `gate '${g.id}' has no 'why'` });
104
105
  }
106
+ // WI-052. `doctor --explain` will not run an undeclared gate against a repo
107
+ // that is not ours, so an author who forgets this silently loses their gate
108
+ // on exactly the repos the analysis exists for. Caught here, where the
109
+ // module is written, rather than as a skip line nobody reads.
110
+ if (g.kind === 'declared' && !g.applicability) {
111
+ issues.push({
112
+ module: mod.name,
113
+ kind: 'gate-no-applicability',
114
+ detail: `gate '${g.id}' does not declare applicability (repo-content | our-artifacts | our-schema)`,
115
+ });
116
+ }
105
117
  }
106
118
  }
107
119
  return issues;
@@ -0,0 +1,221 @@
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
+ // Named files in a parameterised directory, plus the version they are judged
72
+ // against — the changelog shapes. `dir` is stated by the fixture rather than
73
+ // assumed here, because the self-test sees the module's *raw* table and a
74
+ // `{{changelog_dir}}` glob cannot match anything on disk; see `deparam`.
75
+ if (Array.isArray(fx.fragments) && typeof fx.version === 'string') {
76
+ const dir = fx.dir ?? 'changelog.d';
77
+ // Forward slashes, not `join`: the returned paths are matched against the
78
+ // spec's globs, and on Windows `join` yields `changelog.d\0.1.1.md`, which
79
+ // `changelog.d/*.md` does not match. The gate then reports "did not fire"
80
+ // about the harness rather than the fixture.
81
+ const written = fx.fragments.map((n: string) => write(`${dir}/${n}`, `# ${n}\n`));
82
+ written.push(write('package.json', JSON.stringify({ version: fx.version })));
83
+ return written;
84
+ }
85
+
86
+ // N files matching the table's scan — the population shapes.
87
+ if (typeof fx.matching_files === 'number') {
88
+ const base = fx.location ?? dirname(targetPath(table));
89
+ const marker = fx.exempt ? `<!-- ${fx.exempt} -->\n` : '';
90
+ return Array.from({ length: fx.matching_files }, (_, i) =>
91
+ write(join(base === '.' ? '' : base, `probe-${i}.md`), `${marker}# probe ${i}\n`),
92
+ );
93
+ }
94
+
95
+ // A single markdown file described declaratively.
96
+ const contentKeys = ['frontmatter', 'sections', 'opening', 'body', 'row', 'table'];
97
+ if (contentKeys.some((k) => k in fx)) {
98
+ const parts: string[] = [];
99
+ // `table = "Closed"` names the heading the row belongs under. A register
100
+ // engine finds rows by section, so a row written without its heading is in
101
+ // no table at all — which read as "the gate did not fire" (F-018).
102
+ if (fx.table) parts.push(`## ${fx.table}`, '');
103
+ if (fx.frontmatter && typeof fx.frontmatter === 'object') {
104
+ parts.push('---');
105
+ for (const [k, v] of Object.entries(fx.frontmatter)) parts.push(`${k}: ${v}`);
106
+ parts.push('---', '');
107
+ }
108
+ if (fx.opening) parts.push(String(fx.opening), '');
109
+ for (const s of fx.sections ?? []) parts.push(`## ${s}`, '', 'text', '');
110
+ if (fx.row && typeof fx.row === 'object') {
111
+ const cols = Object.keys(fx.row);
112
+ parts.push(`| ${cols.join(' | ')} |`, `| ${cols.map(() => '---').join(' | ')} |`,
113
+ `| ${cols.map((c) => (fx.row as any)[c]).join(' | ')} |`, '');
114
+ }
115
+ if (fx.body) parts.push(String(fx.body), '');
116
+ return [write(fx.file ?? targetPath(table), `${parts.join('\n')}\n`)];
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Engines whose verdict depends **only on the content of the file the fixture
124
+ * describes**, so a fixture can be executed faithfully in an empty directory.
125
+ *
126
+ * The rest need context the fixture does not carry, and running them anyway
127
+ * produces confident nonsense. `gates-links-resolve`'s `pass` fixture is
128
+ * `See [this table](./structural.toml).` — it asserts that a link *which
129
+ * resolves* passes, and in a temp directory it does not resolve, so the runner
130
+ * would report the gate broken. Creating the target to make it pass would be
131
+ * assuming the answer, and only for `expect = "pass"` blocks, which is worse.
132
+ *
133
+ * Measured 2026-08-16: without this restriction the runner reported 17
134
+ * "failures", and the ones inspected were all its own artifacts. A test harness
135
+ * that cries wolf gets deleted faster than the gate it was checking.
136
+ *
137
+ * This is the same shape as `applicability` in ADR-0007, one level down: ask
138
+ * whether the check can legitimately run before running it.
139
+ */
140
+ const CONTEXT_FREE: ReadonlySet<string> = new Set([
141
+ 'frontmatter-schema',
142
+ 'sections',
143
+ 'file-budget',
144
+ 'register-schema',
145
+ 'file-population',
146
+ 'changelog-freshness',
147
+ ]);
148
+
149
+ /**
150
+ * Replace `{{param}}` segments in a spec's globs with the literal the fixture
151
+ * stands in for.
152
+ *
153
+ * The runner reads the **module's** gate table, where paths are still written as
154
+ * parameters — `{{changelog_dir}}/*.md`. Nothing substitutes them here, because
155
+ * there is no installed repo to take values from. So a fixture that exercises a
156
+ * parameterised path has to say what it is standing in for, and the spec has to
157
+ * be told the same thing, or the glob matches a file the builder just wrote and
158
+ * the gate reports "did not fire" about its own harness.
159
+ */
160
+ function deparam<T>(spec: T, dir: string): T {
161
+ const walk = (v: any): any =>
162
+ typeof v === 'string' ? v.replace(/\{\{[^}]+\}\}/g, dir)
163
+ : Array.isArray(v) ? v.map(walk)
164
+ : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
165
+ : v;
166
+ return walk(spec);
167
+ }
168
+
169
+ export function runSelfTests(
170
+ gateId: string,
171
+ engine: string,
172
+ table: any,
173
+ blocks: { expect: string; input?: string; fixture?: any }[],
174
+ ): SelfTestResult[] {
175
+ const out: SelfTestResult[] = [];
176
+ for (const b of blocks) {
177
+ const expect = b.expect === 'fail' ? 'fail' : 'pass';
178
+ if (!CONTEXT_FREE.has(engine)) {
179
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `${engine} fixtures need context the fixture does not carry` });
180
+ continue;
181
+ }
182
+ if (!(engine in ENGINES)) {
183
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `engine '${engine}' not implemented` });
184
+ continue;
185
+ }
186
+ const root = mkdtempSync(join(tmpdir(), 'rungs-selftest-'));
187
+ try {
188
+ const files = build(root, table, b.fixture, b.input);
189
+ // A fixture states an opt-in with `opted_in`; the engine reads it from the
190
+ // spec as `extensions_opted_in`. Without the bridge, the `pass` fixture for
191
+ // an opted-in extension fired `non-spec key` — the harness asserting the
192
+ // opposite of what the fixture said (F-018).
193
+ let spec = b.fixture?.opted_in
194
+ ? (Array.isArray(table) ? table.map((s: any) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in })
195
+ : table;
196
+ // Same bridge, for paths: a fixture that names a parameterised directory
197
+ // has to hand the spec the same literal it wrote the files into.
198
+ if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? 'changelog.d');
199
+ if (!files) {
200
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
201
+ continue;
202
+ }
203
+ let findings: Finding[];
204
+ try {
205
+ findings = ENGINES[engine](spec, root, files).findings;
206
+ } catch (e: any) {
207
+ out.push({ gate: gateId, expect, outcome: 'unrun', detail: `engine threw: ${e.message}`.slice(0, 90) });
208
+ continue;
209
+ }
210
+ const fired = findings.length > 0;
211
+ out.push(
212
+ fired === (expect === 'fail')
213
+ ? { gate: gateId, expect, outcome: 'ok' }
214
+ : { gate: gateId, expect, outcome: 'mismatch', detail: `expected ${expect}, ${fired ? `fired: ${findings[0].message}`.slice(0, 80) : 'did not fire'}` },
215
+ );
216
+ } finally {
217
+ rmSync(root, { recursive: true, force: true });
218
+ }
219
+ }
220
+ return out;
221
+ }
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. */