@rungs/cli 0.1.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/LICENSE +21 -0
- package/README.md +196 -0
- package/modules/README.md +116 -0
- package/modules/adr/files/{{path}}/README.md +52 -0
- package/modules/adr/files/{{path}}/TEMPLATE.md +55 -0
- package/modules/adr/fragments/AGENTS.md +8 -0
- package/modules/adr/gates/adr.toml +65 -0
- package/modules/adr/module.toml +67 -0
- package/modules/audit/files/{{criteria_path}} +43 -0
- package/modules/audit/fragments/AGENTS.md +8 -0
- package/modules/audit/gates/audit.toml +48 -0
- package/modules/audit/module.toml +72 -0
- package/modules/audit/skills/assess/SKILL.md +62 -0
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +52 -0
- package/modules/backlog/files/docs/{{root}}/README.md +110 -0
- package/modules/backlog/files/docs/{{root}}/TEMPLATE.md +56 -0
- package/modules/backlog/files/docs/{{root}}/archive/README.md +13 -0
- package/modules/backlog/files/docs/{{root}}/items/README.md +13 -0
- package/modules/backlog/fragments/AGENTS.md +9 -0
- package/modules/backlog/gates/ids.toml +106 -0
- package/modules/backlog/module.toml +150 -0
- package/modules/backlog/rules/work-items.md +44 -0
- package/modules/backlog/skills/backlog-summary/SKILL.md +65 -0
- package/modules/backlog/skills/work-item/SKILL.md +96 -0
- package/modules/ci/files/{{workflow_path}} +43 -0
- package/modules/ci/gates/ci.toml +44 -0
- package/modules/ci/module.toml +74 -0
- package/modules/concurrency/files/docs/concurrent-sessions.md +87 -0
- package/modules/concurrency/fragments/AGENTS.md +10 -0
- package/modules/concurrency/fragments/gitattributes +15 -0
- package/modules/concurrency/gates/concurrency.toml +54 -0
- package/modules/concurrency/module.toml +113 -0
- package/modules/design-sync/fragments/AGENTS.md +8 -0
- package/modules/design-sync/gates/design.toml +70 -0
- package/modules/design-sync/module.toml +82 -0
- package/modules/design-sync/skills/design-align/SKILL.md +51 -0
- package/modules/design-sync/skills/design-pull/SKILL.md +50 -0
- package/modules/doc-authority/files/{{registry_path}} +59 -0
- package/modules/doc-authority/files/{{rules_path}} +51 -0
- package/modules/doc-authority/fragments/AGENTS.md +9 -0
- package/modules/doc-authority/gates/authority.toml +127 -0
- package/modules/doc-authority/module.toml +111 -0
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +56 -0
- package/modules/findings/fragments/AGENTS.md +7 -0
- package/modules/findings/gates/findings.toml +68 -0
- package/modules/findings/module.toml +100 -0
- package/modules/findings/skills/record-finding/SKILL.md +63 -0
- package/modules/gates/files/.ai/gates.toml +49 -0
- package/modules/gates/fragments/AGENTS.md +8 -0
- package/modules/gates/fragments/gitignore +4 -0
- package/modules/gates/gates/structural.toml +127 -0
- package/modules/gates/module.toml +143 -0
- package/modules/gates/skills/harden-rule/SKILL.md +82 -0
- package/modules/instructions/files/.ai/rules/README.md +53 -0
- package/modules/instructions/files/AGENTS.md +104 -0
- package/modules/instructions/files/CLAUDE.md +11 -0
- package/modules/instructions/gates/core.toml +114 -0
- package/modules/instructions/module.toml +127 -0
- package/modules/release/fragments/AGENTS.md +8 -0
- package/modules/release/gates/release.toml +85 -0
- package/modules/release/module.toml +97 -0
- package/modules/release/skills/cut-release/SKILL.md +81 -0
- package/modules/session/files/{{archive}}/README.md +28 -0
- package/modules/session/files/{{path}} +56 -0
- package/modules/session/fragments/AGENTS.md +6 -0
- package/modules/session/gates/session.toml +63 -0
- package/modules/session/module.toml +72 -0
- package/modules/session/skills/close-session/SKILL.md +59 -0
- package/modules/skills/fragments/AGENTS.md +8 -0
- package/modules/skills/gates/skills.toml +94 -0
- package/modules/skills/module.toml +89 -0
- package/modules/skills/rules/skill-authoring.md +71 -0
- package/modules/specs/files/{{path}}/README.md +63 -0
- package/modules/specs/files/{{path}}/TEMPLATE.md +62 -0
- package/modules/specs/fragments/AGENTS.md +8 -0
- package/modules/specs/gates/specs.toml +119 -0
- package/modules/specs/module.toml +107 -0
- package/modules/workflows/fragments/AGENTS.md +8 -0
- package/modules/workflows/gates/workflows.toml +65 -0
- package/modules/workflows/module.toml +75 -0
- package/modules/workflows/rules/planning-tiers.md +46 -0
- package/modules/workflows/rules/reuse-decision.md +50 -0
- package/modules/workflows/skills/decompose/SKILL.md +75 -0
- package/package.json +42 -0
- package/src/add.ts +331 -0
- package/src/check.ts +216 -0
- package/src/cli.ts +411 -0
- package/src/detect.ts +246 -0
- package/src/engines.ts +260 -0
- package/src/engines2.ts +297 -0
- package/src/engines3.ts +194 -0
- package/src/glob.ts +101 -0
- package/src/lifecycle.ts +268 -0
- package/src/manifest.ts +108 -0
- package/src/render.ts +222 -0
- package/src/substitute.ts +78 -0
- package/src/types.ts +110 -0
package/src/engines.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
import { matchAny, walk } from './glob.ts';
|
|
4
|
+
import {
|
|
5
|
+
computedClaim,
|
|
6
|
+
crossReference,
|
|
7
|
+
filenameSchema,
|
|
8
|
+
gitStatusReconcile,
|
|
9
|
+
idIntegrity,
|
|
10
|
+
registerSchema,
|
|
11
|
+
renderFreshness,
|
|
12
|
+
} from './engines2.ts';
|
|
13
|
+
import { gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
|
|
14
|
+
|
|
15
|
+
export interface Finding {
|
|
16
|
+
file?: string;
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
export interface EngineResult {
|
|
20
|
+
findings: Finding[];
|
|
21
|
+
/** What the engine looked at. A gate that examined nothing is not a passing gate. */
|
|
22
|
+
examined: number;
|
|
23
|
+
}
|
|
24
|
+
export type Engine = (table: any, repoRoot: string, files: string[]) => EngineResult;
|
|
25
|
+
|
|
26
|
+
const read = (root: string, rel: string) => {
|
|
27
|
+
try {
|
|
28
|
+
return readFileSync(join(root, rel), 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `expand` also drops anything the CLI generated.
|
|
36
|
+
*
|
|
37
|
+
* Without it a gate fires on its own tool's output: `render` degrades a
|
|
38
|
+
* path-scoped rule into a nested `docs/plans/AGENTS.md` for harnesses with no
|
|
39
|
+
* glob scoping, and the `workflows` gate — which scans `docs/plans/**` for plan
|
|
40
|
+
* documents — then reported that file for having no plan frontmatter. The
|
|
41
|
+
* render pipeline and the gate scopes collide by construction, so the exclusion
|
|
42
|
+
* is global rather than per-table: **a gate must never fire on a file rungs
|
|
43
|
+
* wrote.**
|
|
44
|
+
*/
|
|
45
|
+
const GENERATED = 'Generated by `rungs';
|
|
46
|
+
|
|
47
|
+
const expand = (files: string[], patterns: string[] | undefined, fallback: string[] = []) =>
|
|
48
|
+
[...new Set((patterns ?? fallback).flatMap((p) => matchAny(files, p)))];
|
|
49
|
+
|
|
50
|
+
function dropGenerated(root: string, rels: string[]): string[] {
|
|
51
|
+
return rels.filter((rel) => !read(root, rel).slice(0, 600).includes(GENERATED));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lines that actually load: HTML comments are stripped by at least one harness. */
|
|
55
|
+
function loadedLines(text: string): number {
|
|
56
|
+
return text
|
|
57
|
+
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
58
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((l) => l.trim()).length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fileBudget: Engine = (t, root, files) => {
|
|
64
|
+
const targets = dropGenerated(root, t.file ? [t.file] : expand(files, t.scan));
|
|
65
|
+
const excluded = new Set(expand(files, t.exclude, []));
|
|
66
|
+
const findings: Finding[] = [];
|
|
67
|
+
let examined = 0;
|
|
68
|
+
for (const rel of targets) {
|
|
69
|
+
if (excluded.has(rel) || !existsSync(join(root, rel))) continue;
|
|
70
|
+
examined++;
|
|
71
|
+
const n = loadedLines(read(root, rel));
|
|
72
|
+
if (n > t.max_lines) findings.push({ file: rel, message: `${n} lines, budget ${t.max_lines}` });
|
|
73
|
+
}
|
|
74
|
+
return { findings, examined };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const sections: Engine = (t, root, files) => {
|
|
78
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
79
|
+
const findings: Finding[] = [];
|
|
80
|
+
let examined = 0;
|
|
81
|
+
for (const spec of specs) {
|
|
82
|
+
const targets = dropGenerated(root, spec.file ? [spec.file] : expand(files, spec.scan));
|
|
83
|
+
const excluded = new Set(expand(files, spec.exclude, []));
|
|
84
|
+
for (const rel of targets) {
|
|
85
|
+
if (excluded.has(rel) || !existsSync(join(root, rel))) continue;
|
|
86
|
+
examined++;
|
|
87
|
+
const text = read(root, rel);
|
|
88
|
+
const heads = [...text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1]);
|
|
89
|
+
for (const want of spec.required ?? []) {
|
|
90
|
+
const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
|
|
91
|
+
if (idx === -1) {
|
|
92
|
+
findings.push({ file: rel, message: `missing section '${want}'` });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (spec.non_empty) {
|
|
96
|
+
const after = text.split(new RegExp(`^#{1,6}\\s+${escapeRe(heads[idx])}\\s*$`, 'm'))[1] ?? '';
|
|
97
|
+
const body = after.split(/^#{1,6}\s+/m)[0].replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
98
|
+
if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const open of spec.requires_opening ?? []) {
|
|
102
|
+
if (!text.slice(0, 400).includes(open)) findings.push({ file: rel, message: `does not open with ${open}` });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { findings, examined };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const frontmatterSchema: Engine = (t, root, files) => {
|
|
110
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
111
|
+
const findings: Finding[] = [];
|
|
112
|
+
let examined = 0;
|
|
113
|
+
for (const spec of specs) {
|
|
114
|
+
for (const rel of dropGenerated(root, expand(files, spec.scan))) {
|
|
115
|
+
if (new Set(expand(files, spec.exclude, [])).has(rel)) continue;
|
|
116
|
+
const text = read(root, rel);
|
|
117
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
118
|
+
if (!m) {
|
|
119
|
+
findings.push({ file: rel, message: 'no frontmatter' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
examined++;
|
|
123
|
+
const keys = [...m[1].matchAll(/^([a-zA-Z0-9_-]+):/gm)].map((k) => k[1]);
|
|
124
|
+
for (const req of spec.required ?? []) {
|
|
125
|
+
if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
|
|
126
|
+
}
|
|
127
|
+
if (spec.allowed) {
|
|
128
|
+
for (const k of keys) {
|
|
129
|
+
if (!spec.allowed.includes(k)) findings.push({ file: rel, message: `non-spec key '${k}'` });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const [key, values] of Object.entries(spec.enum ?? {})) {
|
|
133
|
+
const v = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim().replace(/^["']|["']$/g, '');
|
|
134
|
+
if (v && !(values as string[]).map(String).includes(v)) {
|
|
135
|
+
findings.push({ file: rel, message: `${key}='${v}' not one of ${(values as string[]).join(', ')}` });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { findings, examined };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const linkIntegrity: Engine = (t, root, files) => {
|
|
144
|
+
const scan = expand(files, t.scan, ['**/*.md']); // link checks DO cover generated files
|
|
145
|
+
const excluded = new Set(expand(files, t.exclude, []));
|
|
146
|
+
const findings: Finding[] = [];
|
|
147
|
+
let examined = 0;
|
|
148
|
+
for (const rel of scan) {
|
|
149
|
+
if (excluded.has(rel)) continue;
|
|
150
|
+
const text = read(root, rel);
|
|
151
|
+
// A file with unsubstituted placeholders is a **template**, and its links
|
|
152
|
+
// resolve only once installed. Found by running this against the rungs repo
|
|
153
|
+
// itself, where `modules/*/fragments/AGENTS.md` was reported for a broken
|
|
154
|
+
// link to `{{path}}/README.md` — a path that is not meant to exist here.
|
|
155
|
+
if (/\{\{[a-z_.]+\}\}/.test(text)) continue;
|
|
156
|
+
examined++;
|
|
157
|
+
if (/path-ok:\s*\S/.test(text)) continue;
|
|
158
|
+
for (const m of text.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
|
|
159
|
+
const target = resolve(root, dirname(rel), decodeURIComponent(m[1]));
|
|
160
|
+
if (!existsSync(target)) findings.push({ file: rel, message: `broken link → ${m[1]}` });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { findings, examined };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const filePopulation: Engine = (t, root, files) => {
|
|
167
|
+
let hits = dropGenerated(root, expand(files, t.scan)).filter((f) => !new Set(expand(files, t.exclude, [])).has(f));
|
|
168
|
+
|
|
169
|
+
// A `detect` narrows the population to files matching a shape. Without it the
|
|
170
|
+
// gate counted *every scanned file* — so the redirect-stub check reported
|
|
171
|
+
// "12 matching files, threshold 1" against a repo with no stubs at all, which
|
|
172
|
+
// is a confidently wrong number rather than a finding.
|
|
173
|
+
if (t.detect === 'body_is_only_a_pointer') {
|
|
174
|
+
hits = hits.filter((rel) => {
|
|
175
|
+
const body = read(root, rel)
|
|
176
|
+
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
177
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
178
|
+
.replace(/^#.*$/gm, '')
|
|
179
|
+
.trim();
|
|
180
|
+
const words = body.split(/\s+/).filter(Boolean).length;
|
|
181
|
+
const links = (body.match(/\]\(/g) ?? []).length;
|
|
182
|
+
// Short *and* mostly a link. Short alone is a stub-shaped index page.
|
|
183
|
+
return words > 0 && words <= (t.max_body_words ?? 40) && links >= 1;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const findings: Finding[] = [];
|
|
188
|
+
const failAt = t.fail_at ?? Infinity;
|
|
189
|
+
if (hits.length >= failAt) {
|
|
190
|
+
findings.push({ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` });
|
|
191
|
+
}
|
|
192
|
+
return { findings, examined: hits.length };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The meta-gate: every declared gate must carry a self-test expecting `pass`
|
|
197
|
+
* and one expecting `fail`. A gate whose rules are all currently satisfied is
|
|
198
|
+
* indistinguishable from a gate that matches nothing.
|
|
199
|
+
*/
|
|
200
|
+
const gateMeta: Engine = (_t, root) => {
|
|
201
|
+
const findings: Finding[] = [];
|
|
202
|
+
const registry = join(root, '.ai', 'gates.toml');
|
|
203
|
+
if (!existsSync(registry)) return { findings, examined: 0 };
|
|
204
|
+
const text = readFileSync(registry, 'utf8');
|
|
205
|
+
const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
|
|
206
|
+
let examined = 0;
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const id = entry.match(/^id\s*=\s*"(.+)"/m)?.[1];
|
|
209
|
+
const kind = entry.match(/^kind\s*=\s*"(.+)"/m)?.[1];
|
|
210
|
+
const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
|
|
211
|
+
if (!id || kind !== 'declared' || !table) continue;
|
|
212
|
+
examined++;
|
|
213
|
+
// Tables live in the CLI, not the repo, so read them from the module set.
|
|
214
|
+
const tablePath = join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules', dirname(table), 'gates', table.split('/').pop()!);
|
|
215
|
+
const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
|
|
216
|
+
const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
|
|
217
|
+
.map((m) => m[0])
|
|
218
|
+
.filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
|
|
219
|
+
for (const direction of ['pass', 'fail']) {
|
|
220
|
+
if (!forGate.some((b) => new RegExp(`expect\\s*=\\s*"${direction}"`).test(b))) {
|
|
221
|
+
findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { findings, examined };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
229
|
+
|
|
230
|
+
export const ENGINES: Record<string, Engine> = {
|
|
231
|
+
'file-budget': fileBudget,
|
|
232
|
+
sections,
|
|
233
|
+
'frontmatter-schema': frontmatterSchema,
|
|
234
|
+
'link-integrity': linkIntegrity,
|
|
235
|
+
'file-population': filePopulation,
|
|
236
|
+
'gate-meta': gateMeta,
|
|
237
|
+
'id-integrity': idIntegrity,
|
|
238
|
+
'render-freshness': renderFreshness,
|
|
239
|
+
'register-schema': registerSchema,
|
|
240
|
+
'filename-schema': filenameSchema,
|
|
241
|
+
'cross-reference': crossReference,
|
|
242
|
+
'git-status-reconcile': gitStatusReconcile,
|
|
243
|
+
'computed-claim': computedClaim,
|
|
244
|
+
'term-ownership': termOwnership,
|
|
245
|
+
'rule-propagation': rulePropagation,
|
|
246
|
+
'git-state': gitState,
|
|
247
|
+
'merge-driver-check': mergeDriverCheck,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Engines named in a gate table but not implemented here. Reported by name and
|
|
252
|
+
* treated as blocking — never as a pass.
|
|
253
|
+
*
|
|
254
|
+
* This is the single most dangerous failure this tool could have: a registry of
|
|
255
|
+
* 31 gates reporting green because 25 of them do nothing. rift-forge's rule
|
|
256
|
+
* generalises exactly — *we do not land on an unknown.*
|
|
257
|
+
*/
|
|
258
|
+
export function isImplemented(engine: string): boolean {
|
|
259
|
+
return engine in ENGINES;
|
|
260
|
+
}
|
package/src/engines2.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { matchAny } from './glob.ts';
|
|
5
|
+
import type { Engine, Finding } from './engines.ts';
|
|
6
|
+
|
|
7
|
+
const read = (root: string, rel: string) => {
|
|
8
|
+
try {
|
|
9
|
+
return readFileSync(join(root, rel), 'utf8');
|
|
10
|
+
} catch {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const expand = (files: string[], p: string[] | undefined, f: string[] = []) =>
|
|
15
|
+
[...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
16
|
+
|
|
17
|
+
/** An exemption marker is ignored unless it states a reason. */
|
|
18
|
+
const exempted = (text: string, marker?: string) =>
|
|
19
|
+
!!marker && new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\S`).test(text);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ids: uniqueness across the declared sources, citations that resolve, and the
|
|
23
|
+
* stale-blocker rule — a document may not say it waits on work that has finished.
|
|
24
|
+
*/
|
|
25
|
+
export const idIntegrity: Engine = (t, root, files) => {
|
|
26
|
+
const findings: Finding[] = [];
|
|
27
|
+
let examined = 0;
|
|
28
|
+
const known = new Set<string>();
|
|
29
|
+
|
|
30
|
+
for (const [, kind] of Object.entries<any>(t.kinds ?? {})) {
|
|
31
|
+
const re = new RegExp(`^\\s*id:\\s*(${kind.format})`, 'm');
|
|
32
|
+
const seen = new Map<string, string>();
|
|
33
|
+
for (const rel of expand(files, kind.sources)) {
|
|
34
|
+
examined++;
|
|
35
|
+
const text = read(root, rel);
|
|
36
|
+
const id = text.match(re)?.[1] ?? rel.match(new RegExp(kind.format))?.[0];
|
|
37
|
+
if (!id) continue;
|
|
38
|
+
known.add(id);
|
|
39
|
+
const prior = seen.get(id);
|
|
40
|
+
if (prior) findings.push({ file: rel, message: `id ${id} also claimed by ${prior}` });
|
|
41
|
+
else seen.set(id, rel);
|
|
42
|
+
}
|
|
43
|
+
// The marker must not name an id that is already spent.
|
|
44
|
+
if (kind.marker?.file) {
|
|
45
|
+
const m = read(root, kind.marker.file).match(new RegExp(kind.marker.pattern));
|
|
46
|
+
if (m?.[1] && seen.has(m[1])) {
|
|
47
|
+
findings.push({ file: kind.marker.file, message: `NEXT marker points at ${m[1]}, already taken` });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Stale blockers. Vocabulary is narrow on purpose: a first draft that matched
|
|
53
|
+
// `until <id>` hit 29 lines of true history in one repo's own voice.
|
|
54
|
+
const sb = t.stale_blocker;
|
|
55
|
+
if (sb?.phrases?.length && known.size) {
|
|
56
|
+
const scope = expand(files, ['docs/**/*.md', 'AGENTS.md', 'CLAUDE.md']).filter(
|
|
57
|
+
(f) => !expand(files, sb.scope_exclude, []).includes(f),
|
|
58
|
+
);
|
|
59
|
+
const past = (sb.past_tense_ok ?? []).map((p: string) => p.toLowerCase());
|
|
60
|
+
for (const rel of scope) {
|
|
61
|
+
const text = read(root, rel);
|
|
62
|
+
if (exempted(text, sb.exempt_marker)) continue;
|
|
63
|
+
for (const phrase of sb.phrases) {
|
|
64
|
+
const re = new RegExp(`(.{0,${sb.negation_window ?? 60}})\\b${phrase}\\b\\s+([A-Z]{1,6}-\\d{1,4})`, 'gi');
|
|
65
|
+
for (const m of text.matchAll(re)) {
|
|
66
|
+
const lead = m[1].toLowerCase();
|
|
67
|
+
if (past.some((p: string) => lead.includes(p.split(' ')[0]) && lead.includes('was'))) continue;
|
|
68
|
+
if (/\bnot\b|\bnever\b|\bno longer\b/.test(lead.slice(-30))) continue;
|
|
69
|
+
if (isDone(root, m[2], files)) {
|
|
70
|
+
findings.push({ file: rel, message: `claims to be ${phrase} ${m[2]}, which is done` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { findings, examined };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function isDone(root: string, id: string, files: string[]): boolean {
|
|
80
|
+
const hit = files.find((f) => f.includes(id) && f.endsWith('.md'));
|
|
81
|
+
if (!hit) return false;
|
|
82
|
+
const s = read(root, hit).match(/^status:\s*(\S+)/m)?.[1];
|
|
83
|
+
return s === 'done' || hit.includes('/archive/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Generated output that no longer matches what its producer would emit now. */
|
|
87
|
+
export const renderFreshness: Engine = (t, root, files) => {
|
|
88
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
89
|
+
const findings: Finding[] = [];
|
|
90
|
+
let examined = 0;
|
|
91
|
+
for (const spec of specs) {
|
|
92
|
+
if (spec.block?.file) {
|
|
93
|
+
examined++;
|
|
94
|
+
const text = read(root, spec.block.file);
|
|
95
|
+
const re = new RegExp(`rungs:begin ${spec.block.marker}[\\s\\S]*?rungs:end ${spec.block.marker}`);
|
|
96
|
+
if (!re.test(text)) {
|
|
97
|
+
findings.push({ file: spec.block.file, message: `no '${spec.block.marker}' block — run \`${spec.command}\`` });
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const excluded = new Set(expand(files, spec.exclude, []));
|
|
102
|
+
const sources = expand(files, spec.sources).filter((s) => !excluded.has(s));
|
|
103
|
+
const targets = expand(files, spec.targets);
|
|
104
|
+
// Only harnesses this repo actually emits for are checked; a missing
|
|
105
|
+
// `.cursor/rules` in a repo that never asked for Cursor is not staleness.
|
|
106
|
+
const live = new Set(targets.map((x) => x.split('/')[0]));
|
|
107
|
+
for (const src of sources) {
|
|
108
|
+
examined++;
|
|
109
|
+
const stem = src.split('/').pop()!.replace(/\.md$/, '');
|
|
110
|
+
for (const dir of live) {
|
|
111
|
+
if (!targets.some((x) => x.startsWith(dir) && x.includes(stem))) {
|
|
112
|
+
findings.push({ file: src, message: `no rendering under ${dir}/ — run \`${spec.command}\`` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { findings, examined };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Markdown-table registers: required columns, enums, and conditional rules. */
|
|
121
|
+
export const registerSchema: Engine = (t, root, files) => {
|
|
122
|
+
const findings: Finding[] = [];
|
|
123
|
+
let examined = 0;
|
|
124
|
+
const targets = t.file ? [t.file] : expand(files, t.scan);
|
|
125
|
+
for (const rel of targets) {
|
|
126
|
+
const text = read(root, rel);
|
|
127
|
+
if (!text) continue;
|
|
128
|
+
for (const table of parseTables(text)) {
|
|
129
|
+
if (t.table && !sectionOf(text, table.headerLine).toLowerCase().includes(String(t.table).toLowerCase())) continue;
|
|
130
|
+
const cols = t.required_cols ?? t.table_columns ?? [];
|
|
131
|
+
const present = cols.filter((c: string) =>
|
|
132
|
+
table.headers.some((h) => h.toLowerCase() === String(c).toLowerCase()),
|
|
133
|
+
);
|
|
134
|
+
// Recognition before validation: a file may hold several tables and only
|
|
135
|
+
// some are registers. Demanding every one carry the columns reported a
|
|
136
|
+
// spec index for not being a story table.
|
|
137
|
+
if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
|
|
138
|
+
for (const c of cols) {
|
|
139
|
+
if (!present.includes(c)) findings.push({ file: rel, message: `register table missing column '${c}'` });
|
|
140
|
+
}
|
|
141
|
+
for (const row of table.rows) {
|
|
142
|
+
if (Object.values(row).every((v) => !v || v === '—')) continue;
|
|
143
|
+
examined++;
|
|
144
|
+
for (const [key, values] of Object.entries<any>(t.enum ?? {})) {
|
|
145
|
+
const v = strip(row[key]);
|
|
146
|
+
if (v && !values.map(String).includes(v)) {
|
|
147
|
+
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(', ')}` });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const c of t.non_empty ?? []) {
|
|
151
|
+
if (!strip(row[c])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c}' is empty` });
|
|
152
|
+
}
|
|
153
|
+
for (const cond of t.conditional ?? []) {
|
|
154
|
+
const matches = Object.entries<any>(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
|
|
155
|
+
if (!matches) continue;
|
|
156
|
+
for (const c of cond.non_empty ?? []) {
|
|
157
|
+
const v = strip(row[c]);
|
|
158
|
+
if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c}' required when ${JSON.stringify(cond.when)}` });
|
|
159
|
+
else if (cond.min_words?.[c] && v.split(/\s+/).length < cond.min_words[c]) {
|
|
160
|
+
findings.push({ file: rel, message: `row ${firstCell(row)}: '${c}' is too thin to be a reason` });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { findings, examined };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const strip = (v?: string) => (v ?? '').replace(/[`*\[\]]/g, '').split('(')[0].trim();
|
|
171
|
+
const firstCell = (row: Record<string, string>) => strip(Object.values(row)[0]) || '?';
|
|
172
|
+
|
|
173
|
+
function parseTables(text: string) {
|
|
174
|
+
const out: { headers: string[]; rows: Record<string, string>[]; headerLine: number }[] = [];
|
|
175
|
+
const lines = text.split('\n');
|
|
176
|
+
for (let i = 0; i < lines.length; i++) {
|
|
177
|
+
if (!/^\s*\|/.test(lines[i]) || !/^\s*\|[\s:|-]+\|/.test(lines[i + 1] ?? '')) continue;
|
|
178
|
+
const headers = cells(lines[i]);
|
|
179
|
+
const rows: Record<string, string>[] = [];
|
|
180
|
+
let j = i + 2;
|
|
181
|
+
for (; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
|
|
182
|
+
const c = cells(lines[j]);
|
|
183
|
+
rows.push(Object.fromEntries(headers.map((h, k) => [h, c[k] ?? ''])));
|
|
184
|
+
}
|
|
185
|
+
out.push({ headers, rows, headerLine: i });
|
|
186
|
+
i = j;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const cells = (line: string) => line.trim().replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
|
|
191
|
+
const sectionOf = (text: string, line: number) => {
|
|
192
|
+
const before = text.split('\n').slice(0, line);
|
|
193
|
+
for (let i = before.length - 1; i >= 0; i--) if (/^#{1,6}\s/.test(before[i])) return before[i];
|
|
194
|
+
return '';
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const filenameSchema: Engine = (t, root, files) => {
|
|
198
|
+
const re = new RegExp(t.pattern);
|
|
199
|
+
const excluded = new Set(expand(files, t.exclude, []));
|
|
200
|
+
const findings: Finding[] = [];
|
|
201
|
+
let examined = 0;
|
|
202
|
+
for (const rel of expand(files, t.scan)) {
|
|
203
|
+
if (excluded.has(rel)) continue;
|
|
204
|
+
examined++;
|
|
205
|
+
const base = rel.split('/').pop()!;
|
|
206
|
+
if (!re.test(base)) findings.push({ file: rel, message: 'filename does not say what closed and what came next' });
|
|
207
|
+
}
|
|
208
|
+
return { findings, examined };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Skills naming their neighbours — only past the threshold where it matters. */
|
|
212
|
+
export const crossReference: Engine = (t, root, files) => {
|
|
213
|
+
const skills = expand(files, t.scan);
|
|
214
|
+
if (skills.length < (t.min_skills ?? 6)) return { findings: [], examined: skills.length };
|
|
215
|
+
const names = skills.map((s) => s.split('/').slice(-2)[0]);
|
|
216
|
+
const findings: Finding[] = [];
|
|
217
|
+
for (const rel of skills) {
|
|
218
|
+
const text = read(root, rel);
|
|
219
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
220
|
+
const desc = text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
|
|
221
|
+
const self = rel.split('/').slice(-2)[0];
|
|
222
|
+
if (!names.some((n) => n !== self && desc.includes(n))) {
|
|
223
|
+
findings.push({ file: rel, message: `names no neighbouring skill (${skills.length} in this repo)` });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { findings, examined: skills.length };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** A merged branch cannot still sit at a pre-review status. One-directional. */
|
|
230
|
+
export const gitStatusReconcile: Engine = (t, root, files) => {
|
|
231
|
+
const findings: Finding[] = [];
|
|
232
|
+
let merged: Set<string>;
|
|
233
|
+
try {
|
|
234
|
+
merged = new Set(
|
|
235
|
+
execSync(`git branch --merged ${t.integration_branch ?? 'main'} --format=%(refname:short)`, {
|
|
236
|
+
cwd: root,
|
|
237
|
+
stdio: 'pipe',
|
|
238
|
+
})
|
|
239
|
+
.toString()
|
|
240
|
+
.split('\n')
|
|
241
|
+
.map((s) => s.trim())
|
|
242
|
+
.filter(Boolean),
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
// No git, or no such branch. Not a pass and not a failure — an unknown.
|
|
246
|
+
return { findings: [{ message: 'cannot read git branches; status not reconciled' }], examined: 0 };
|
|
247
|
+
}
|
|
248
|
+
let examined = 0;
|
|
249
|
+
for (const rel of expand(files, ['docs/**/items/**/*.md'])) {
|
|
250
|
+
const text = read(root, rel);
|
|
251
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
252
|
+
const branch = text.match(new RegExp(`^${t.branch_field ?? 'branch'}:\\s*(\\S+)`, 'm'))?.[1];
|
|
253
|
+
const status = text.match(new RegExp(`^${t.status_field ?? 'status'}:\\s*(\\S+)`, 'm'))?.[1];
|
|
254
|
+
if (!branch || !status) continue;
|
|
255
|
+
examined++;
|
|
256
|
+
if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
|
|
257
|
+
findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { findings, examined };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** A number a machine can compute is never typed by a human. */
|
|
264
|
+
export const computedClaim: Engine = (t, root, files) => {
|
|
265
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
266
|
+
const findings: Finding[] = [];
|
|
267
|
+
let examined = 0;
|
|
268
|
+
for (const spec of specs) {
|
|
269
|
+
const values = new Map<string, string>();
|
|
270
|
+
for (const src of spec.sources ?? []) {
|
|
271
|
+
for (const rel of matchAny(files, src.file)) {
|
|
272
|
+
const text = read(root, rel);
|
|
273
|
+
let v: string | undefined;
|
|
274
|
+
if (src.path && rel.endsWith('.json')) {
|
|
275
|
+
try {
|
|
276
|
+
v = src.path.split('.').reduce((o: any, k: string) => o?.[k], JSON.parse(text));
|
|
277
|
+
} catch {
|
|
278
|
+
/* unparseable is not a disagreement */
|
|
279
|
+
}
|
|
280
|
+
} else if (src.xpath) {
|
|
281
|
+
v = text.match(new RegExp(`<${src.xpath.split('//')[1]}>(.*?)<`))?.[1];
|
|
282
|
+
}
|
|
283
|
+
if (v) {
|
|
284
|
+
examined++;
|
|
285
|
+
values.set(rel, String(v));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const distinct = new Set(values.values());
|
|
290
|
+
if (spec.rule === 'all-agree' && distinct.size > 1) {
|
|
291
|
+
findings.push({
|
|
292
|
+
message: `${spec.id} disagrees across ${values.size} locations: ${[...distinct].join(', ')} — run \`${spec.autofix}\``,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { findings, examined };
|
|
297
|
+
};
|