@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.
- package/README.md +61 -11
- package/dist/cli.js +1250 -129
- package/dist/cli.js.map +4 -4
- package/modules/README.md +13 -0
- package/modules/adr/gates/adr.toml +14 -2
- package/modules/adr/module.toml +19 -1
- package/modules/audit/module.toml +1 -0
- package/modules/backlog/gates/ids.toml +33 -0
- package/modules/backlog/module.toml +61 -1
- package/modules/ci/files/{{workflow_path}} +9 -1
- package/modules/ci/module.toml +2 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
- package/modules/concurrency/module.toml +3 -1
- package/modules/design-sync/module.toml +2 -0
- package/modules/doc-authority/module.toml +4 -0
- package/modules/findings/module.toml +3 -0
- package/modules/gates/gates/structural.toml +61 -17
- package/modules/gates/module.toml +5 -0
- package/modules/instructions/module.toml +4 -0
- package/modules/release/gates/release.toml +72 -4
- package/modules/release/module.toml +16 -1
- package/modules/release/skills/cut-release/SKILL.md +8 -1
- package/modules/session/module.toml +4 -2
- package/modules/skills/module.toml +3 -0
- package/modules/specs/module.toml +4 -0
- package/modules/workflows/module.toml +2 -0
- package/package.json +1 -1
- package/src/add.ts +64 -2
- package/src/backlog.ts +197 -0
- package/src/check.ts +56 -6
- package/src/cli.ts +406 -27
- package/src/concurrency.ts +412 -0
- package/src/engines.ts +261 -13
- package/src/engines2.ts +89 -4
- package/src/engines3.ts +147 -0
- package/src/explain.ts +189 -0
- package/src/lifecycle.ts +90 -3
- package/src/manifest.ts +13 -1
- package/src/selftest.ts +237 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
}
|