@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
package/src/engines.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, dirname, resolve } from 'node:path';
3
3
  import { matchAny, walk } from './glob.ts';
4
+ import { parse as parseToml } from 'smol-toml';
5
+ import { runSelfTests } from './selftest.ts';
6
+ import { loadAllModules } from './manifest.ts';
7
+ import { resolveParams, substitute } from './substitute.ts';
4
8
  import {
5
9
  computedClaim,
6
10
  crossReference,
@@ -11,7 +15,7 @@ import {
11
15
  renderFreshness,
12
16
  selfDeclaredClosure,
13
17
  } from './engines2.ts';
14
- import { gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
18
+ import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
15
19
 
16
20
  export interface Finding {
17
21
  file?: string;
@@ -70,7 +74,13 @@ const fileBudget: Engine = (t, root, files) => {
70
74
  if (excluded.has(rel) || !existsSync(join(root, rel))) continue;
71
75
  examined++;
72
76
  const n = loadedLines(read(root, rel));
73
- if (n > t.max_lines) findings.push({ file: rel, message: `${n} lines, budget ${t.max_lines}` });
77
+ // "1358 lines" invites `wc -l`, which answered 1413 on the same file
78
+ // (hexguard-templates, 2026-08-16) because this counts what actually *loads*
79
+ // — frontmatter, HTML comments and blank lines stripped. Naming the measure
80
+ // is the difference between evidence and a number the reader disproves.
81
+ if (n > t.max_lines) {
82
+ findings.push({ file: rel, message: `${n} loaded lines (blank lines and comments excluded), budget ${t.max_lines}` });
83
+ }
74
84
  }
75
85
  return { findings, examined };
76
86
  };
@@ -86,7 +96,8 @@ const sections: Engine = (t, root, files) => {
86
96
  if (excluded.has(rel) || !existsSync(join(root, rel))) continue;
87
97
  examined++;
88
98
  const text = read(root, rel);
89
- const heads = [...text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1]);
99
+ const matches = [...text.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)];
100
+ const heads = matches.map((m) => m[2]);
90
101
  for (const want of spec.required ?? []) {
91
102
  const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
92
103
  if (idx === -1) {
@@ -94,8 +105,18 @@ const sections: Engine = (t, root, files) => {
94
105
  continue;
95
106
  }
96
107
  if (spec.non_empty) {
97
- const after = text.split(new RegExp(`^#{1,6}\\s+${escapeRe(heads[idx])}\\s*$`, 'm'))[1] ?? '';
98
- const body = after.split(/^#{1,6}\s+/m)[0].replace(/<!--[\s\S]*?-->/g, '').trim();
108
+ // Content runs to the next heading of the **same or higher** level, so
109
+ // a section made of subsections is not empty. Splitting on any heading
110
+ // reported ADR-0002's `## Decision` as empty because a `### (a)`
111
+ // follows it immediately — the first finding this gate produced, and a
112
+ // false one (F-018). A section whose whole body is subsections is the
113
+ // normal shape for a long decision.
114
+ const level = matches[idx][1].length;
115
+ const after = text.slice(matches[idx].index! + matches[idx][0].length);
116
+ const body = after
117
+ .split(new RegExp(`^#{1,${level}}\\s+`, 'm'))[0]
118
+ .replace(/<!--[\s\S]*?-->/g, '')
119
+ .trim();
99
120
  if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
100
121
  }
101
122
  }
@@ -107,7 +128,7 @@ const sections: Engine = (t, root, files) => {
107
128
  return { findings, examined };
108
129
  };
109
130
 
110
- const frontmatterSchema: Engine = (t, root, files) => {
131
+ export const frontmatterSchema: Engine = (t, root, files) => {
111
132
  const specs = Array.isArray(t) ? t : [t];
112
133
  const findings: Finding[] = [];
113
134
  let examined = 0;
@@ -126,22 +147,129 @@ const frontmatterSchema: Engine = (t, root, files) => {
126
147
  if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
127
148
  }
128
149
  if (spec.allowed) {
150
+ // `extensions_allowed_from` names the manifest that may legalise a
151
+ // non-spec key. It was declared in the skills table and read nowhere
152
+ // (F-019), so an extension a module had deliberately opted into was
153
+ // indistinguishable from one somebody typed by mistake — and the
154
+ // portability cost the opt-in exists to record was attached to nothing.
155
+ const optedIn = spec.extensions_allowed_from ? optedInExtensions(rel, spec) : new Set<string>();
129
156
  for (const k of keys) {
130
- if (!spec.allowed.includes(k)) findings.push({ file: rel, message: `non-spec key '${k}'` });
157
+ if (spec.allowed.includes(k) || optedIn.has(k)) continue;
158
+ findings.push({ file: rel, message: `non-spec key '${k}'` });
131
159
  }
132
160
  }
161
+ const field = (k: string) => m[1].match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1].trim().replace(/^["']|["']$/g, '');
133
162
  for (const [key, values] of Object.entries(spec.enum ?? {})) {
134
- const v = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim().replace(/^["']|["']$/g, '');
163
+ const v = field(key);
135
164
  if (v && !(values as string[]).map(String).includes(v)) {
136
165
  findings.push({ file: rel, message: `${key}='${v}' not one of ${(values as string[]).join(', ')}` });
137
166
  }
138
167
  }
168
+
169
+ // `[frontmatter_schema.reciprocal]` was configured in the `adr` table and
170
+ // implemented nowhere — the third instance of F-007's shape, and this one
171
+ // was found by *executing* a self-test rather than by reading (F-018).
172
+ // Its fail fixture is a `superseded` record with no `superseded_by`, which
173
+ // could never have failed while nothing read the rule.
174
+ //
175
+ // A one-way supersession leaves a reader on the stale record with no route
176
+ // to the live one, which is the whole point of recording it.
177
+ for (const pair of spec.reciprocal?.pairs ?? []) {
178
+ const from = field(pair.from);
179
+ if (!from) continue;
180
+ const target = expand(files, spec.scan).find((r) => r.includes(from.replace(/\.md$/, '')));
181
+ if (!target) {
182
+ findings.push({ file: rel, message: `${pair.from} names '${from}', which is not a record here` });
183
+ continue;
184
+ }
185
+ const back = read(root, target).match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
186
+ const id = field('id') ?? '';
187
+ if (!new RegExp(`^${pair.to}:\\s*.*${escapeRe(id)}`, 'm').test(back)) {
188
+ findings.push({ file: rel, message: `${pair.from} → ${from}, but it does not name this record back in '${pair.to}'` });
189
+ }
190
+ }
191
+ // A status that implies the pairing must actually carry it.
192
+ for (const [status, requires] of Object.entries(spec.reciprocal?.required_when ?? {})) {
193
+ if (field('status') === status && !field(String(requires))) {
194
+ findings.push({ file: rel, message: `status is '${status}' but '${requires}' is absent` });
195
+ }
196
+ }
139
197
  }
140
198
  }
141
199
  return { findings, examined };
142
200
  };
143
201
 
144
- const linkIntegrity: Engine = (t, root, files) => {
202
+ /**
203
+ * Does a link target resolve, under any reading of it?
204
+ *
205
+ * `path/to/file.ts:387` is a code reference, not a broken link — it is the form
206
+ * `CLAUDE.md` mandates in this repo ("Reference code as `file_path:line_number`")
207
+ * and the form editors and terminals click. The engine resolved it literally and
208
+ * reported the file missing while the file sat exactly there.
209
+ *
210
+ * Measured 2026-08-16 on `rift-forge` via `doctor --explain`: **1,794 of 3,851
211
+ * link findings — 46.6% — were this**, every one a real file with a line number
212
+ * after it. Latent since WI-008 made link checking per-link; before that, one
213
+ * `{{token}}` anywhere in a file exempted every link in it, which hid it.
214
+ *
215
+ * Resolve as written first, and only then retry without a trailing `:line` or
216
+ * `:line:col`. Strip-and-retest rather than strip-and-assume: a link is called
217
+ * broken only when **no** reading of it resolves, so this can only ever remove
218
+ * findings. Stripping unconditionally would silence a genuinely missing
219
+ * `foo.ts:12` whenever an equally missing `foo.ts` explained it away.
220
+ */
221
+ function resolvesHere(root: string, rel: string, href: string): boolean {
222
+ const from = dirname(rel);
223
+ const decoded = decodeURIComponent(href);
224
+ if (existsSync(resolve(root, from, decoded))) return true;
225
+ const stripped = decoded.replace(/:\d+(?::\d+)?$/, '');
226
+ return stripped !== decoded && existsSync(resolve(root, from, stripped));
227
+ }
228
+
229
+ /**
230
+ * A backticked path in an instruction file — `docs/backlog/BACKLOG.md` — that no
231
+ * longer exists. F-007: `backticked_paths` was named in the table's `check` list
232
+ * and implemented nowhere, so `gates-paths-exist` silently ran the markdown-link
233
+ * scan instead and reported every finding a second time.
234
+ *
235
+ * Resolved from the **repo root**, because that is what an instruction file's
236
+ * reader does with a path it is told to go and read. Skipped when the span is a
237
+ * glob, a template token, a URL, or has no path shape at all: prose is full of
238
+ * `identifiers`, and a gate that refuses every code span is one people delete.
239
+ */
240
+ function backtickedPaths(rel: string, text: string, root: string, hints: string[]): Finding[] {
241
+ const out: Finding[] = [];
242
+ const seen = new Set<string>();
243
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
244
+ const raw = m[1].trim();
245
+ if (seen.has(raw) || !hints.some((h) => raw.includes(h))) continue;
246
+ seen.add(raw);
247
+
248
+ // Every exclusion below is a measured false positive from the first run of
249
+ // this check against this repo, 2026-08-16 — which produced ten findings,
250
+ // all ten wrong. The table's own instruction is that under-detection is the
251
+ // correct bias, so the shape is narrowed to the incident it was written for:
252
+ // hexguard's instruction files naming a **repo-relative file** that moved.
253
+ if (raw.startsWith('/')) continue; // `/work-item` — a skill invocation
254
+ if (/[*?{}#\s]|^\w+:/.test(raw)) continue; // globs, `WI-###` placeholders, prose, URLs
255
+ if (!raw.includes('/')) continue; // `work-items.md` — a bare name, anchor unknown
256
+ if (!/\.[a-z0-9]{1,5}$/i.test(raw)) continue; // `.cursor/rules/` — a directory, often illustrative
257
+
258
+ // Two anchors, because instruction files use both: repo-root paths for "go
259
+ // read this", and `../` paths relative to the file itself.
260
+ const bare = raw.replace(/^\.\//, '');
261
+ if (!existsSync(join(root, bare)) && !existsSync(resolve(root, dirname(rel), bare))) {
262
+ out.push({ message: `stale path in a code span → ${raw}` });
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+
268
+ export const linkIntegrity: Engine = (t, root, files) => {
269
+ // The table is now one entry per gate (F-007). The runner hands an array
270
+ // through when it selects by id; take the single entry it selected.
271
+ if (Array.isArray(t)) t = t[0] ?? {};
272
+ const checks: string[] = t.check ?? ['relative_markdown_links'];
145
273
  const scan = expand(files, t.scan, ['**/*.md']); // link checks DO cover generated files
146
274
  const excluded = new Set(expand(files, t.exclude, []));
147
275
  const findings: Finding[] = [];
@@ -151,6 +279,10 @@ const linkIntegrity: Engine = (t, root, files) => {
151
279
  const text = read(root, rel);
152
280
  examined++;
153
281
  if (/path-ok:\s*\S/.test(text)) continue;
282
+ if (checks.includes('backticked_paths')) {
283
+ findings.push(...backtickedPaths(rel, text, root, t.path_hint ?? ['/']).map((f) => ({ ...f, file: rel })));
284
+ }
285
+ if (!checks.includes('relative_markdown_links')) continue;
154
286
  // A link written inside a code span is prose *quoting* a link — most often a document
155
287
  // explaining that some link is wrong. Blanked rather than removed, so every offset after it
156
288
  // is unchanged and the reported text still matches what the author sees.
@@ -163,8 +295,7 @@ const linkIntegrity: Engine = (t, root, files) => {
163
295
  // The case the file-level skip was written for — `modules/*/fragments/AGENTS.md` linking
164
296
  // `{{path}}/README.md` — is already excluded by path in `link_integrity.exclude` (WI-008).
165
297
  if (/\{\{[a-z_.]+\}\}/.test(m[1])) continue;
166
- const target = resolve(root, dirname(rel), decodeURIComponent(m[1]));
167
- if (!existsSync(target)) findings.push({ file: rel, message: `broken link → ${m[1]}` });
298
+ if (!resolvesHere(root, rel, m[1])) findings.push({ file: rel, message: `broken link → ${m[1]}` });
168
299
  }
169
300
  }
170
301
  return { findings, examined };
@@ -194,7 +325,17 @@ const filePopulation: Engine = (t, root, files) => {
194
325
  const findings: Finding[] = [];
195
326
  const failAt = t.fail_at ?? Infinity;
196
327
  if (hits.length >= failAt) {
197
- findings.push({ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` });
328
+ // The count is only re-derivable if the reader knows what was counted.
329
+ // `audit-output-is-rows` reported 275 on hexguard while the obvious
330
+ // one-pattern `find` answered 268 (2026-08-16) — the gate scans three
331
+ // patterns, and the message named none of them, so the correct number read
332
+ // as a wrong one.
333
+ const scanned = [t.scan ?? []].flat();
334
+ findings.push({
335
+ message:
336
+ `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` +
337
+ (scanned.length ? ` — matched against ${scanned.join(', ')}` : ''),
338
+ });
198
339
  }
199
340
  return { findings, examined: hits.length };
200
341
  };
@@ -204,8 +345,11 @@ const filePopulation: Engine = (t, root, files) => {
204
345
  * and one expecting `fail`. A gate whose rules are all currently satisfied is
205
346
  * indistinguishable from a gate that matches nothing.
206
347
  */
207
- const gateMeta: Engine = (_t, root) => {
348
+ export const gateMeta: Engine = (_t, root) => {
208
349
  const findings: Finding[] = [];
350
+
351
+ let unrun = 0;
352
+
209
353
  const registry = join(root, '.ai', 'gates.toml');
210
354
  if (!existsSync(registry)) return { findings, examined: 0 };
211
355
  const text = readFileSync(registry, 'utf8');
@@ -228,12 +372,114 @@ const gateMeta: Engine = (_t, root) => {
228
372
  findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
229
373
  }
230
374
  }
375
+
376
+ // WI-045 / F-018: declaring is not asserting. Every fixture whose shape and
377
+ // engine can be reproduced faithfully is executed, and a disagreement is a
378
+ // finding of this gate.
379
+ //
380
+ // Turning this on found, in order: `adr`'s orphaned `[sections]` table, two
381
+ // fixtures labelled for a gate that checks something else, a `reciprocal`
382
+ // rule read by nothing, a `non_empty` check that called any section with
383
+ // subsections empty, three fixtures orphaned by a schema that moved modules,
384
+ // `session-sections-present` wired to an engine whose table it does not have
385
+ // (so it passed by examining nothing), `[register_schema.open]` read by
386
+ // nothing, and a table matcher loose enough that "resolve-open-findings" in
387
+ // a filename made a Closed section match the Open schema.
388
+ //
389
+ // None of it was visible while the fixtures were documentation.
390
+ const engine = entry.match(/^engine\s*=\s*"(.+)"/m)?.[1];
391
+ const parsed = parseTable(tablePath, table.split('/')[0]);
392
+ if (engine && parsed) {
393
+ const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : [])
394
+ .filter((b: any) => b?.gate === id)
395
+ .map((b: any) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
396
+ for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
397
+ if (r.outcome === 'mismatch') findings.push({ message: `self-test for '${id}' ${r.detail}` });
398
+ else if (r.outcome === 'unrun') unrun++;
399
+ }
400
+ }
231
401
  }
402
+
403
+ // Stated, never silent. Most fixtures still cannot be reproduced — their
404
+ // shapes need context the format does not carry — and reporting green while
405
+ // most of the suite never executed would be F-006 one level up. A note rather
406
+ // than a failure: an unbuildable fixture is not a defect in the gate.
407
+ if (unrun) console.error(` ${unrun} self-test fixture(s) have no builder and did not run — not passes (F-018)`);
232
408
  return { findings, examined };
233
409
  };
234
410
 
411
+ /**
412
+ * Which non-spec frontmatter keys are legal for this skill, because the module
413
+ * that ships it opted in.
414
+ *
415
+ * The skill's name is its directory — `.claude/skills/work-item/SKILL.md` — and
416
+ * the answer lives in whichever module declares `[skills.work-item]`. Read from
417
+ * the CLI's module set rather than from the repo, so a consumer cannot legalise
418
+ * an extension by editing a file: the opt-in belongs to the module that took the
419
+ * portability cost.
420
+ *
421
+ * `extensions_opted_in` overrides it, which is how a self-test fixture states
422
+ * the opt-in without needing a module on disk.
423
+ */
424
+ function optedInExtensions(rel: string, spec: any): Set<string> {
425
+ if (Array.isArray(spec.extensions_opted_in)) return new Set(spec.extensions_opted_in.map(String));
426
+ const name = rel.split('/').slice(-2)[0];
427
+ if (!name) return new Set();
428
+ try {
429
+ const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
430
+ const owner = mods.find((m) => m.skills?.[name]?.extensions);
431
+ return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
432
+ } catch {
433
+ return new Set();
434
+ }
435
+ }
436
+
235
437
  const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
236
438
 
439
+ /**
440
+ * A gate table, parsed. Substitution is deliberately *not* applied: a fixture's
441
+ * `{{token}}` is part of what it asserts, and the runner needs the table's raw
442
+ * shape rather than one repo's resolved parameters.
443
+ */
444
+ function parseTable(path: string, module: string): any | null {
445
+ if (!existsSync(path)) return null;
446
+ try {
447
+ // Substituted with the module's **real defaults**, not a placeholder. Using
448
+ // `probe` turned the skills schema's scan into `probe/**/SKILL.md` while its
449
+ // fixture still wrote `.claude/skills/x/SKILL.md`, so the engine saw no file
450
+ // and the runner reported the gate broken — a mismatch entirely of the
451
+ // harness's making. A fixture and the table it tests must resolve against
452
+ // the same parameters or neither means anything.
453
+ const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
454
+ const params = resolveParams(mods, {}, '.');
455
+ return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+
461
+ /** Duplicated from `check.ts` rather than imported, to keep engines dependency-free of the runner. */
462
+ const tableKeyFor = (engine: string) =>
463
+ ({
464
+ 'file-budget': 'file_budget',
465
+ 'frontmatter-schema': 'frontmatter_schema',
466
+ 'link-integrity': 'link_integrity',
467
+ 'file-population': 'file_population',
468
+ 'render-freshness': 'render_freshness',
469
+ 'register-schema': 'register_schema',
470
+ 'self-declared-closure': 'self_declared_closure',
471
+ 'filename-schema': 'filename_schema',
472
+ 'cross-reference': 'cross_reference',
473
+ 'git-status-reconcile': 'merged_status',
474
+ 'computed-claim': 'computed_claim',
475
+ 'term-ownership': 'term_ownership',
476
+ 'rule-propagation': 'rule_propagation',
477
+ 'git-state': 'git_state',
478
+ 'merge-driver-check': 'merge_driver_check',
479
+ 'board-reconcile': 'board_reconcile',
480
+ 'changelog-freshness': 'changelog_freshness',
481
+ })[engine] ?? engine;
482
+
237
483
  export const ENGINES: Record<string, Engine> = {
238
484
  'file-budget': fileBudget,
239
485
  sections,
@@ -253,6 +499,8 @@ export const ENGINES: Record<string, Engine> = {
253
499
  'rule-propagation': rulePropagation,
254
500
  'git-state': gitState,
255
501
  'merge-driver-check': mergeDriverCheck,
502
+ 'board-reconcile': boardReconcile,
503
+ 'changelog-freshness': changelogFreshness,
256
504
  };
257
505
 
258
506
  /**
package/src/engines2.ts CHANGED
@@ -121,12 +121,31 @@ export const renderFreshness: Engine = (t, root, files) => {
121
121
  export const registerSchema: Engine = (t, root, files) => {
122
122
  const findings: Finding[] = [];
123
123
  let examined = 0;
124
- const targets = t.file ? [t.file] : expand(files, t.scan);
124
+
125
+ // A register file holds more than one table, and each gets its own spec —
126
+ // `[register_schema]` for Closed and `[register_schema.open]` for Open. Only
127
+ // the top-level one was ever read (F-018), so the Open table's rules —
128
+ // `non_empty = ["Sev", "Pri", "What", "Evidence"]` and the Sev/Pri enums —
129
+ // had never been enforced on any repo. Found because the fixture asserting
130
+ // them could not fire, once fixtures started running.
131
+ //
132
+ // A sub-spec is any nested object naming a `table`; `enum` and `min_words` are
133
+ // objects too and do not.
134
+ const specs = [t, ...Object.values(t).filter((v: any) => v && typeof v === 'object' && !Array.isArray(v) && v.table)];
135
+ for (const t of specs as any[]) {
136
+ const targets = t.file ?? specs[0].file ? [t.file ?? (specs[0] as any).file] : expand(files, t.scan);
125
137
  for (const rel of targets) {
126
138
  const text = read(root, rel);
127
139
  if (!text) continue;
128
140
  for (const table of parseTables(text)) {
129
- if (t.table && !sectionOf(text, table.headerLine).toLowerCase().includes(String(t.table).toLowerCase())) continue;
141
+ // The heading must **start with** the table's name, not merely contain it.
142
+ // A substring match sent every Closed row through the Open schema, because
143
+ // `## Closed — 2026-08-16 by [WI-044](archive/WI-044-resolve-open-findings.md)`
144
+ // contains "open" inside a filename. Latent until `[register_schema.open]`
145
+ // was read for the first time (F-018) — a loose matcher is invisible while
146
+ // only one spec exists to match.
147
+ const heading = sectionOf(text, table.headerLine).replace(/^#+\s*/, '').trim().toLowerCase();
148
+ if (t.table && !heading.startsWith(String(t.table).toLowerCase())) continue;
130
149
  const cols = t.required_cols ?? t.table_columns ?? [];
131
150
  const present = cols.filter((c: string) =>
132
151
  table.headers.some((h) => h.toLowerCase() === String(c).toLowerCase()),
@@ -164,6 +183,7 @@ export const registerSchema: Engine = (t, root, files) => {
164
183
  }
165
184
  }
166
185
  }
186
+ }
167
187
  return { findings, examined };
168
188
  };
169
189
 
@@ -297,6 +317,48 @@ export const crossReference: Engine = (t, root, files) => {
297
317
  };
298
318
 
299
319
  /** A merged branch cannot still sit at a pre-review status. One-directional. */
320
+ /**
321
+ * Did this branch actually land work, or is it a label pointing at a commit the
322
+ * base already had?
323
+ *
324
+ * `git branch --merged` answers "is the tip an ancestor", which is true of a
325
+ * branch cut five seconds ago and never committed to. F-001: reproduced
326
+ * 2026-08-15 on WI-001 and hit three more times on 2026-08-16 — every item
327
+ * worked through `/work-item` trips it in the window between `git switch -c`
328
+ * and the first commit. A gate that cries wolf on the happy path is one people
329
+ * learn to ignore, which is the failure it exists to prevent.
330
+ *
331
+ * The obvious fix — "has commits ahead of base" — is wrong, and measuring it
332
+ * proved so: **after any merge the branch is zero commits ahead**, so the gate
333
+ * would never fire again. That silently deletes the check while looking like a
334
+ * fix, which is worse than the false positive.
335
+ *
336
+ * What actually distinguishes them is the merge commit. This repo merges
337
+ * `--no-ff` (backlog README §4), so a branch that landed work leaves a commit in
338
+ * the base whose *second* parent is that branch's tip. A branch that landed
339
+ * nothing never appears as anyone's second parent.
340
+ *
341
+ * **Known gap, stated rather than hidden:** a fast-forward merge that keeps the
342
+ * branch produces no merge commit and no second parent, so this reads it as
343
+ * having landed nothing and stays quiet. That is a false negative on a workflow
344
+ * this repo does not use — it deletes branches on merge — and it is the
345
+ * direction to be wrong in, because the alternative is the daily false positive.
346
+ */
347
+ function landedWork(root: string, branch: string, base: string): boolean {
348
+ const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: root, stdio: 'pipe' }).toString().trim();
349
+ try {
350
+ const tip = git(`rev-parse ${branch}`);
351
+ if (tip === git(`rev-parse ${base}`)) return false;
352
+ return git(`log ${base} --merges --format=%P`)
353
+ .split('\n')
354
+ .some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
355
+ } catch {
356
+ // Unreadable is not provably empty. Report, which fails loudly rather than
357
+ // silently — the same rule the runner applies to a missing engine.
358
+ return true;
359
+ }
360
+ }
361
+
300
362
  export const gitStatusReconcile: Engine = (t, root, files) => {
301
363
  const findings: Finding[] = [];
302
364
  let merged: Set<string>;
@@ -323,7 +385,11 @@ export const gitStatusReconcile: Engine = (t, root, files) => {
323
385
  const status = text.match(new RegExp(`^${t.status_field ?? 'status'}:\\s*(\\S+)`, 'm'))?.[1];
324
386
  if (!branch || !status) continue;
325
387
  examined++;
326
- if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
388
+ if (
389
+ merged.has(branch) &&
390
+ (t.pre_review_statuses ?? []).includes(status) &&
391
+ landedWork(root, branch, t.integration_branch ?? 'main')
392
+ ) {
327
393
  findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
328
394
  }
329
395
  }
@@ -337,8 +403,17 @@ export const computedClaim: Engine = (t, root, files) => {
337
403
  let examined = 0;
338
404
  for (const spec of specs) {
339
405
  const values = new Map<string, string>();
406
+ // Which files share a version is the repo's judgement, not something to infer
407
+ // (F-023). The default sources glob `*/package.json`, which is right for a
408
+ // monorepo released in lockstep and wrong for a sibling that is deliberately
409
+ // versioned on its own — this repo's docs site sat at 0.0.1 beside a 0.2.0
410
+ // package, correctly, and installing the gate would have failed a healthy
411
+ // layout. So a repo states the exceptions rather than the engine guessing
412
+ // them, and `all-agree` keeps needing no opinion about which file is right.
413
+ const excluded = (rel: string) => (spec.exclude ?? []).some((p: string) => matchAny([rel], p).length > 0);
340
414
  for (const src of spec.sources ?? []) {
341
415
  for (const rel of matchAny(files, src.file)) {
416
+ if (excluded(rel)) continue;
342
417
  const text = read(root, rel);
343
418
  let v: string | undefined;
344
419
  if (src.path && rel.endsWith('.json')) {
@@ -358,8 +433,18 @@ export const computedClaim: Engine = (t, root, files) => {
358
433
  }
359
434
  const distinct = new Set(values.values());
360
435
  if (spec.rule === 'all-agree' && distinct.size > 1) {
436
+ // Name the file beside its value. The message used to list the distinct
437
+ // values and then say "run `{autofix}`" — which pointed at
438
+ // `rungs release sync-version`, a command that does not exist and never
439
+ // has. Telling someone to run a missing command is worse than telling
440
+ // them nothing, so the finding now carries what they actually need: which
441
+ // file says what. The hint is appended only if a real one is declared.
442
+ const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(', ');
361
443
  findings.push({
362
- message: `${spec.id} disagrees across ${values.size} locations: ${[...distinct].join(', ')} — run \`${spec.autofix}\``,
444
+ message:
445
+ `${spec.id} disagrees across ${values.size} locations: ${where}` +
446
+ (spec.autofix ? ` — run \`${spec.autofix}\`` : '') +
447
+ (spec.exclude?.length ? '' : '. If one of these is versioned independently, list it in `exclude`.'),
363
448
  });
364
449
  }
365
450
  }