@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/README.md +45 -8
- package/dist/cli.js +883 -127
- 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/module.toml +1 -0
- package/modules/concurrency/module.toml +2 -0
- 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 +43 -0
- 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 +182 -0
- package/src/check.ts +47 -4
- package/src/cli.ts +297 -27
- package/src/engines.ts +261 -13
- package/src/engines2.ts +69 -3
- 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 +221 -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
|
}
|
package/src/engines3.ts
CHANGED
|
@@ -15,6 +15,72 @@ const expand = (files: string[], p: string[] | undefined, f: string[] = []) =>
|
|
|
15
15
|
[...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
16
16
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
17
|
|
|
18
|
+
/** `1.2.3` → [1,2,3]; anything else → null. Deliberately not a semver parser. */
|
|
19
|
+
export function versionParts(s: string): number[] | null {
|
|
20
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
|
|
21
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function versionCmp(a: number[], b: number[]): number {
|
|
25
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `changelog-freshness` — a consumed fragment that was never deleted.
|
|
30
|
+
*
|
|
31
|
+
* Fragments are consumed at release time, not archived: one left behind appears
|
|
32
|
+
* in the next release too, where it reads as unreleased work. `cut-release` §3
|
|
33
|
+
* has said so in prose since it was written, and prose did not hold it —
|
|
34
|
+
* `changelog.d/0.1.1.md` survived two releases and was still there at 0.2.0
|
|
35
|
+
* preparation (F-022). So the rule becomes mechanical.
|
|
36
|
+
*
|
|
37
|
+
* A fragment is stale when its filename names a version **below** the version
|
|
38
|
+
* being prepared. Files whose names are not versions are ignored rather than
|
|
39
|
+
* reported: the module's own fixtures use `42.feature.md`, and a gate that
|
|
40
|
+
* refuses a naming convention it was not asked about is a gate people disable.
|
|
41
|
+
*/
|
|
42
|
+
export const changelogFreshness: Engine = (t, root, files) => {
|
|
43
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
44
|
+
const findings: Finding[] = [];
|
|
45
|
+
let examined = 0;
|
|
46
|
+
|
|
47
|
+
for (const spec of specs) {
|
|
48
|
+
const src = spec.version ?? {};
|
|
49
|
+
let current: number[] | null = null;
|
|
50
|
+
for (const rel of matchAny(files, src.file ?? 'package.json')) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = (src.path ?? 'version')
|
|
53
|
+
.split('.')
|
|
54
|
+
.reduce((o: any, k: string) => o?.[k], JSON.parse(read(root, rel)));
|
|
55
|
+
current = versionParts(String(raw ?? ''));
|
|
56
|
+
} catch {
|
|
57
|
+
/* unparseable is not a stale fragment */
|
|
58
|
+
}
|
|
59
|
+
if (current) break;
|
|
60
|
+
}
|
|
61
|
+
// Without a version to compare against there is no claim to make. Saying
|
|
62
|
+
// nothing is right; passing loudly would not be.
|
|
63
|
+
if (!current) continue;
|
|
64
|
+
|
|
65
|
+
for (const rel of expand(files, spec.fragments, [])) {
|
|
66
|
+
const name = rel.split('/').pop()!.replace(/\.md$/, '');
|
|
67
|
+
const v = versionParts(name);
|
|
68
|
+
if (!v) continue;
|
|
69
|
+
examined++;
|
|
70
|
+
if (versionCmp(v, current) < 0) {
|
|
71
|
+
findings.push({
|
|
72
|
+
file: rel,
|
|
73
|
+
message:
|
|
74
|
+
spec.message?.trim() ||
|
|
75
|
+
`fragment names ${name}, below the ${current.join('.')} being prepared — it was consumed by an earlier release and should have been deleted`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { findings, examined };
|
|
82
|
+
};
|
|
83
|
+
|
|
18
84
|
/** An exemption marker is ignored unless it states a reason. */
|
|
19
85
|
const exempted = (text: string, marker?: string) =>
|
|
20
86
|
!!marker && new RegExp(`${escapeRe(marker)}\\s*\\S`).test(text);
|
|
@@ -192,3 +258,84 @@ export const mergeDriverCheck: Engine = (t, root) => {
|
|
|
192
258
|
}
|
|
193
259
|
return { findings, examined: declared.length };
|
|
194
260
|
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The board's grouping must agree with each item's own `status` field.
|
|
264
|
+
*
|
|
265
|
+
* `git-status-reconcile` already reconciles a **branch** against that field, and
|
|
266
|
+
* this repo cites it constantly as proof that typed bookkeeping decays. Nothing
|
|
267
|
+
* reconciled the **board** — so on 2026-08-16 `BACKLOG.md` filed fourteen items
|
|
268
|
+
* under `Proposed` and `Planned` whose files all read `status: done`, nine of
|
|
269
|
+
* them linking into `archive/`. The board said *proposed* about a document in
|
|
270
|
+
* the directory for work that can no longer change.
|
|
271
|
+
*
|
|
272
|
+
* It was found by an outside reviewer asserting the framework research was done.
|
|
273
|
+
* They were right; the board would have told them otherwise. That is the same
|
|
274
|
+
* failure the whole module exists to prevent, one layer up, in the file every
|
|
275
|
+
* session opens first.
|
|
276
|
+
*
|
|
277
|
+
* Only table rows are read. The board's prose deliberately discusses finished
|
|
278
|
+
* work, and a paragraph is not a claim about status.
|
|
279
|
+
*/
|
|
280
|
+
export const boardReconcile: Engine = (t, root, _files) => {
|
|
281
|
+
const rel = t.file as string;
|
|
282
|
+
const text = read(root, rel);
|
|
283
|
+
if (!text) return { findings: [{ message: `board not found at ${rel}` }], examined: 0 };
|
|
284
|
+
if (exempted(text, t.exempt_marker)) return { findings: [], examined: 0 };
|
|
285
|
+
|
|
286
|
+
const groups: Record<string, string[]> = t.groups ?? {};
|
|
287
|
+
const dir = rel.split('/').slice(0, -1).join('/');
|
|
288
|
+
const findings: Finding[] = [];
|
|
289
|
+
let heading = '';
|
|
290
|
+
let examined = 0;
|
|
291
|
+
|
|
292
|
+
for (const line of text.split('\n')) {
|
|
293
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
294
|
+
if (h) {
|
|
295
|
+
heading = h[1];
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!line.startsWith('|')) continue;
|
|
299
|
+
|
|
300
|
+
const link = /^\|\s*\[[^\]]+\]\(([^)]+)\)/.exec(line);
|
|
301
|
+
if (!link) continue; // separator, header, or an empty `| — |` placeholder
|
|
302
|
+
|
|
303
|
+
// An undeclared heading is narrative, not a status group. The board's later
|
|
304
|
+
// sections are prose with their own tables — "The first-user path", closed
|
|
305
|
+
// 2026-08-15, tabulates seven finished items and says so in the heading.
|
|
306
|
+
//
|
|
307
|
+
// Reporting those was this gate's first behaviour and it was wrong: measured
|
|
308
|
+
// 2026-08-16, it produced seven findings against a document that is correct.
|
|
309
|
+
// The plan's requirement that every undeclared heading be reported was aimed
|
|
310
|
+
// at a *typo* hiding rows from the check, and it caught legitimate prose
|
|
311
|
+
// instead. That case is covered exactly, below, by requiring each declared
|
|
312
|
+
// group to appear — a misspelled `Propsed` makes `Proposed` go missing.
|
|
313
|
+
if (!Object.hasOwn(groups, heading)) continue;
|
|
314
|
+
|
|
315
|
+
examined++;
|
|
316
|
+
const target = `${dir}/${link[1]}`.replace(/[^/]+\/\.\.\//g, '');
|
|
317
|
+
const item = read(root, target);
|
|
318
|
+
if (!item) {
|
|
319
|
+
findings.push({ file: rel, message: `row under '${heading}' links to a missing file: ${link[1]}` });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const status = /^status:\s*(\S+)/m.exec(item)?.[1] ?? '';
|
|
323
|
+
if (!groups[heading].includes(status)) {
|
|
324
|
+
findings.push({
|
|
325
|
+
file: rel,
|
|
326
|
+
message: `${link[1]} is under '${heading}' but its status is '${status}' (expected ${groups[heading].join(' | ')})`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Every declared group must actually appear. This is the typo check: a board
|
|
332
|
+
// whose `Proposed` heading is misspelled would otherwise drop those rows
|
|
333
|
+
// silently, which is exactly what the group map exists to prevent.
|
|
334
|
+
const seen = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((m) => m[1]));
|
|
335
|
+
for (const g of Object.keys(groups)) {
|
|
336
|
+
if (!seen.has(g)) findings.push({ file: rel, message: `declared group '${g}' has no heading in the board` });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { findings, examined };
|
|
340
|
+
};
|
|
341
|
+
|