@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/engines3.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
|
|
18
|
+
/** An exemption marker is ignored unless it states a reason. */
|
|
19
|
+
const exempted = (text: string, marker?: string) =>
|
|
20
|
+
!!marker && new RegExp(`${escapeRe(marker)}\\s*\\S`).test(text);
|
|
21
|
+
|
|
22
|
+
/** Rows of the first markdown table under a heading containing `near`. */
|
|
23
|
+
function tableRows(text: string, near?: string): Record<string, string>[] {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
const rows: Record<string, string>[] = [];
|
|
26
|
+
let heading = '';
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
if (/^#{1,6}\s/.test(lines[i])) heading = lines[i];
|
|
29
|
+
if (!/^\s*\|/.test(lines[i]) || !/^\s*\|[\s:|-]+\|/.test(lines[i + 1] ?? '')) continue;
|
|
30
|
+
if (near && !heading.toLowerCase().includes(near.toLowerCase())) continue;
|
|
31
|
+
const cells = (l: string) => l.trim().replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
|
|
32
|
+
const headers = cells(lines[i]);
|
|
33
|
+
for (let j = i + 2; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
|
|
34
|
+
const c = cells(lines[j]);
|
|
35
|
+
rows.push(Object.fromEntries(headers.map((h, k) => [h, c[k] ?? ''])));
|
|
36
|
+
}
|
|
37
|
+
i = lines.length;
|
|
38
|
+
}
|
|
39
|
+
return rows;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const clean = (v = '') => v.replace(/[`*\[\]]/g, '').trim();
|
|
43
|
+
|
|
44
|
+
/** Words distinctive enough to indicate a topic is being restated, not mentioned. */
|
|
45
|
+
function terms(topic: string): string[] {
|
|
46
|
+
const stop = new Set(['the', 'and', 'for', 'with', 'per', 'its', 'a', 'an', 'of', 'to', 'in', 'on', 'is', 'are']);
|
|
47
|
+
return clean(topic)
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.split(/[^a-z0-9_-]+/)
|
|
50
|
+
.filter((w) => w.length > 3 && !stop.has(w));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One owner per topic, checked by vocabulary.
|
|
55
|
+
*
|
|
56
|
+
* The registry's third column — where a topic must NOT appear — is what turns
|
|
57
|
+
* "one source of truth" from a principle into a lookup. Approximate by
|
|
58
|
+
* construction: it catches a section that restates a topic, not one that
|
|
59
|
+
* restates it in different words. That ceiling is pinned in the message rather
|
|
60
|
+
* than hidden, so a green run never reads as "verified".
|
|
61
|
+
*/
|
|
62
|
+
export const termOwnership: Engine = (t, root, files) => {
|
|
63
|
+
const registry = read(root, t.registry ?? 'docs/doc-ownership.md');
|
|
64
|
+
if (!registry) return { findings: [{ message: `ownership registry '${t.registry}' not found` }], examined: 0 };
|
|
65
|
+
|
|
66
|
+
const cols = t.columns ?? {};
|
|
67
|
+
const findings: Finding[] = [];
|
|
68
|
+
let examined = 0;
|
|
69
|
+
|
|
70
|
+
for (const row of tableRows(registry)) {
|
|
71
|
+
const topic = clean(row[cols.topic ?? 'Topic']);
|
|
72
|
+
const owner = clean(row[cols.owner ?? 'Owner']);
|
|
73
|
+
const forbidden = clean(row[cols.forbidden ?? 'Must NOT appear in']);
|
|
74
|
+
if (!topic || !forbidden || forbidden === '—' || topic.startsWith('(example)')) continue;
|
|
75
|
+
|
|
76
|
+
const want = terms(topic);
|
|
77
|
+
if (want.length < 2) continue; // too vague to test without guessing
|
|
78
|
+
const patterns = forbidden.split(/[,·]/).map((s) => s.trim()).filter(Boolean);
|
|
79
|
+
|
|
80
|
+
for (const rel of expand(files, patterns)) {
|
|
81
|
+
if (rel === owner) continue;
|
|
82
|
+
const text = read(root, rel);
|
|
83
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
84
|
+
examined++;
|
|
85
|
+
// Per section, not per file: a passing mention is a cross-reference, a
|
|
86
|
+
// section carrying several of the topic's terms is a restatement.
|
|
87
|
+
for (const section of text.split(/^#{1,6}\s+/m)) {
|
|
88
|
+
const lower = section.toLowerCase();
|
|
89
|
+
const hits = want.filter((w) => lower.includes(w));
|
|
90
|
+
if (hits.length >= (t.engage_min_terms ?? 3)) {
|
|
91
|
+
findings.push({ file: rel, message: `restates "${topic}", owned by ${owner} (${hits.length} terms)` });
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { findings, examined };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A working rule lives in more surfaces than its authority, and fixing the
|
|
102
|
+
* authority does not reach them. Each declared rule names the surfaces that
|
|
103
|
+
* restate it; a surface carrying the retired wording is reported.
|
|
104
|
+
*
|
|
105
|
+
* `forbids` is matched against a preceding-context negation window, because a
|
|
106
|
+
* retired phrase inside "do NOT <retired>" is the fix, not the violation — and
|
|
107
|
+
* a guard that refuses its own fix is one people disable.
|
|
108
|
+
*/
|
|
109
|
+
export const rulePropagation: Engine = (t, root, files) => {
|
|
110
|
+
const registry = read(root, t.registry ?? 'docs/working-rules.md');
|
|
111
|
+
if (!registry) return { findings: [{ message: `rules registry '${t.registry}' not found` }], examined: 0 };
|
|
112
|
+
|
|
113
|
+
const cols = t.columns ?? {};
|
|
114
|
+
const findings: Finding[] = [];
|
|
115
|
+
let examined = 0;
|
|
116
|
+
const window = t.negation_window ?? 60;
|
|
117
|
+
|
|
118
|
+
for (const row of tableRows(registry)) {
|
|
119
|
+
const rule = clean(row[cols.rule ?? 'Rule']);
|
|
120
|
+
const retired = clean(row[cols.retired ?? 'Retired wording']);
|
|
121
|
+
const surfaces = clean(row[cols.surfaces ?? 'Surfaces that restate it']);
|
|
122
|
+
if (!rule || !retired || retired === '—' || rule.startsWith('(example)')) continue;
|
|
123
|
+
|
|
124
|
+
for (const rel of expand(files, surfaces.split(/[,·]/).map((s) => s.trim()).filter(Boolean))) {
|
|
125
|
+
const text = read(root, rel);
|
|
126
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
127
|
+
examined++;
|
|
128
|
+
const re = new RegExp(`(.{0,${window}})${escapeRe(retired)}`, 'gis');
|
|
129
|
+
for (const m of text.matchAll(re)) {
|
|
130
|
+
const lead = m[1].toLowerCase();
|
|
131
|
+
if (/\bnot\b|\bnever\b|\bno longer\b|\bused to\b|\bformerly\b|\bretired\b/.test(lead)) continue;
|
|
132
|
+
findings.push({ file: rel, message: `carries the retired wording for "${rule}"` });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { findings, examined };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The integration branch must be checked out nowhere.
|
|
142
|
+
*
|
|
143
|
+
* Recorded as a correction rather than a preference: holding it checked out
|
|
144
|
+
* blocked every other session *and* did not prevent concurrent landing anyway,
|
|
145
|
+
* because switching to the scratch ref releases it mid-run.
|
|
146
|
+
*/
|
|
147
|
+
export const gitState: Engine = (t, root) => {
|
|
148
|
+
let out: string;
|
|
149
|
+
try {
|
|
150
|
+
out = execSync('git worktree list --porcelain', { cwd: root, stdio: 'pipe' }).toString();
|
|
151
|
+
} catch {
|
|
152
|
+
// Not a git repo, or git unavailable. An unattributable result blocks:
|
|
153
|
+
// we do not land on an unknown.
|
|
154
|
+
return { findings: [{ message: 'cannot read git worktrees; checkout state unknown' }], examined: 0 };
|
|
155
|
+
}
|
|
156
|
+
const findings: Finding[] = [];
|
|
157
|
+
const blocks = out.split('\n\n').filter(Boolean);
|
|
158
|
+
for (const b of blocks) {
|
|
159
|
+
const dir = b.match(/^worktree (.+)$/m)?.[1];
|
|
160
|
+
const branch = b.match(/^branch refs\/heads\/(.+)$/m)?.[1];
|
|
161
|
+
if (branch && (t.refuse_checked_out ?? []).includes(branch)) {
|
|
162
|
+
findings.push({ message: `'${branch}' is checked out in ${dir} — nothing should hold it` });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { findings, examined: blocks.length };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merge drivers named in `.gitattributes` are **inert until installed**, so a
|
|
170
|
+
* fresh clone silently falls back to git's default merge on files that must
|
|
171
|
+
* never be text-merged. Declaring them is not the same as having them.
|
|
172
|
+
*/
|
|
173
|
+
export const mergeDriverCheck: Engine = (t, root) => {
|
|
174
|
+
const attrs = read(root, t.attributes_file ?? '.gitattributes');
|
|
175
|
+
if (!attrs) return { findings: [], examined: 0 };
|
|
176
|
+
|
|
177
|
+
const declared = [...new Set([...attrs.matchAll(/merge=([\w-]+)/g)].map((m) => m[1]))];
|
|
178
|
+
const required = (t.required_drivers ?? []).filter((d: string) => declared.includes(d));
|
|
179
|
+
if (!required.length) return { findings: [], examined: declared.length };
|
|
180
|
+
|
|
181
|
+
const findings: Finding[] = [];
|
|
182
|
+
for (const driver of required) {
|
|
183
|
+
let configured = '';
|
|
184
|
+
try {
|
|
185
|
+
configured = execSync(`git config --get merge.${driver}.driver`, { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
186
|
+
} catch {
|
|
187
|
+
/* absent config exits non-zero, which is the finding */
|
|
188
|
+
}
|
|
189
|
+
if (!configured) {
|
|
190
|
+
findings.push({ message: `driver '${driver}' is declared but not installed — run \`${t.install_command}\`` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { findings, examined: declared.length };
|
|
194
|
+
};
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A small glob matcher: `**`, `*`, `?`, and `{a,b}` brace groups.
|
|
6
|
+
*
|
|
7
|
+
* Written rather than depended on so the semantics are ours. The only rule that
|
|
8
|
+
* matters is the one ADR-0004 states: when a pattern is ambiguous it must fail
|
|
9
|
+
* to match. A false negative creates something visible in git; a false positive
|
|
10
|
+
* makes the CLI believe wrong things about a repo and act on them later.
|
|
11
|
+
*/
|
|
12
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
13
|
+
let out = '';
|
|
14
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
15
|
+
const c = pattern[i];
|
|
16
|
+
if (c === '*') {
|
|
17
|
+
if (pattern[i + 1] === '*') {
|
|
18
|
+
// `**/` consumes any number of segments, including none.
|
|
19
|
+
if (pattern[i + 2] === '/') {
|
|
20
|
+
out += '(?:[^/]+/)*';
|
|
21
|
+
i += 2;
|
|
22
|
+
} else {
|
|
23
|
+
out += '.*';
|
|
24
|
+
i += 1;
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
out += '[^/]*';
|
|
28
|
+
}
|
|
29
|
+
} else if (c === '?') {
|
|
30
|
+
out += '[^/]';
|
|
31
|
+
} else if (c === '{') {
|
|
32
|
+
const end = pattern.indexOf('}', i);
|
|
33
|
+
if (end === -1) {
|
|
34
|
+
out += '\\{';
|
|
35
|
+
} else {
|
|
36
|
+
const alts = pattern.slice(i + 1, end).split(',');
|
|
37
|
+
out += `(?:${alts.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`;
|
|
38
|
+
i = end;
|
|
39
|
+
}
|
|
40
|
+
} else if ('.+^$()|[]\\'.includes(c)) {
|
|
41
|
+
out += `\\${c}`;
|
|
42
|
+
} else {
|
|
43
|
+
out += c;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return new RegExp(`^${out}$`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SKIP = new Set([
|
|
50
|
+
'.git',
|
|
51
|
+
'node_modules',
|
|
52
|
+
'dist',
|
|
53
|
+
'build',
|
|
54
|
+
'out',
|
|
55
|
+
'bin',
|
|
56
|
+
'obj',
|
|
57
|
+
'.vs',
|
|
58
|
+
'.angular',
|
|
59
|
+
'.next',
|
|
60
|
+
'coverage',
|
|
61
|
+
'TestResults',
|
|
62
|
+
'BenchmarkDotNet.Artifacts',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Walk a repo once; callers match the resulting relative paths. */
|
|
66
|
+
export function walk(root: string, maxEntries = 200_000): string[] {
|
|
67
|
+
const files: string[] = [];
|
|
68
|
+
const stack = [root];
|
|
69
|
+
while (stack.length && files.length < maxEntries) {
|
|
70
|
+
const dir = stack.pop()!;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
if (SKIP.has(e.name)) continue;
|
|
79
|
+
const full = join(dir, e.name);
|
|
80
|
+
if (e.isDirectory()) {
|
|
81
|
+
stack.push(full);
|
|
82
|
+
} else if (e.isFile()) {
|
|
83
|
+
files.push(relative(root, full).split(sep).join('/'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function matchAny(files: string[], pattern: string): string[] {
|
|
91
|
+
const re = globToRegExp(pattern);
|
|
92
|
+
return files.filter((f) => re.test(f));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isDir(p: string): boolean {
|
|
96
|
+
try {
|
|
97
|
+
return statSync(p).isDirectory();
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { parse } from 'smol-toml';
|
|
6
|
+
import type { Manifest } from './types.ts';
|
|
7
|
+
import { contentHash, emittedFiles } from './add.ts';
|
|
8
|
+
import { resolveParams, substitute, type Params } from './substitute.ts';
|
|
9
|
+
import { loadRegistry } from './check.ts';
|
|
10
|
+
|
|
11
|
+
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** Bundles from the module catalogue. `init` offers these, not a list of fifteen. */
|
|
14
|
+
export const PROFILES: Record<string, string[]> = {
|
|
15
|
+
minimal: ['instructions'],
|
|
16
|
+
tracked: ['instructions', 'gates', 'backlog', 'findings', 'adr', 'session'],
|
|
17
|
+
disciplined: ['instructions', 'gates', 'backlog', 'findings', 'adr', 'session', 'ci', 'specs', 'workflows', 'skills', 'audit'],
|
|
18
|
+
hardened: ['instructions', 'gates', 'backlog', 'findings', 'adr', 'session', 'ci', 'specs', 'workflows', 'skills', 'audit', 'release', 'doc-authority'],
|
|
19
|
+
fleet: ['instructions', 'gates', 'backlog', 'findings', 'adr', 'session', 'ci', 'specs', 'workflows', 'skills', 'audit', 'release', 'doc-authority', 'concurrency', 'design-sync'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface InstallRecord {
|
|
23
|
+
harnesses: string[];
|
|
24
|
+
modules: Record<string, { version: string; params?: Record<string, unknown>; hashes?: Record<string, string>; kept?: { files: string[] } }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readRecord(repoRoot: string): InstallRecord | null {
|
|
28
|
+
const p = join(repoRoot, '.ai', 'rungs.toml');
|
|
29
|
+
if (!existsSync(p)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const raw = parse(readFileSync(p, 'utf8')) as any;
|
|
32
|
+
return { harnesses: raw.repo?.harnesses ?? [], modules: raw.modules ?? {} };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FileState = 'current' | 'diverged' | 'stale' | 'missing';
|
|
39
|
+
|
|
40
|
+
export interface UpgradeItem {
|
|
41
|
+
module: string;
|
|
42
|
+
from: string;
|
|
43
|
+
to: string;
|
|
44
|
+
files: { rel: string; state: FileState }[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compare what is on disk against both the recorded hash and what the module
|
|
49
|
+
* would emit now. Those two comparisons answer different questions:
|
|
50
|
+
*
|
|
51
|
+
* matches recorded, matches current → current, nothing to do
|
|
52
|
+
* matches recorded, differs current → **stale**, ours to replace
|
|
53
|
+
* differs recorded → **diverged**, theirs; never touched
|
|
54
|
+
*
|
|
55
|
+
* Without the recorded hash the middle two collapse, and upgrade would either
|
|
56
|
+
* clobber deliberate edits or refuse to move anything.
|
|
57
|
+
*/
|
|
58
|
+
export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord): UpgradeItem[] {
|
|
59
|
+
const params = resolveParams(mods, paramsFrom(record));
|
|
60
|
+
const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
61
|
+
const items: UpgradeItem[] = [];
|
|
62
|
+
|
|
63
|
+
for (const mod of mods) {
|
|
64
|
+
const installed = record.modules[mod.name];
|
|
65
|
+
if (!installed) continue;
|
|
66
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
67
|
+
const files: UpgradeItem['files'] = [];
|
|
68
|
+
const kept = new Set(installed.kept?.files ?? []);
|
|
69
|
+
for (const [rel, wouldEmit] of emitted) {
|
|
70
|
+
if (kept.has(rel)) continue; // never ours; upgrade does not touch it
|
|
71
|
+
const full = join(repoRoot, rel);
|
|
72
|
+
if (!existsSync(full)) {
|
|
73
|
+
files.push({ rel, state: 'missing' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const onDisk = contentHash(readFileSync(full, 'utf8'));
|
|
77
|
+
const recorded = installed.hashes?.[rel];
|
|
78
|
+
if (onDisk === contentHash(wouldEmit)) files.push({ rel, state: 'current' });
|
|
79
|
+
else if (recorded && onDisk === recorded) files.push({ rel, state: 'stale' });
|
|
80
|
+
else files.push({ rel, state: 'diverged' });
|
|
81
|
+
}
|
|
82
|
+
items.push({ module: mod.name, from: installed.version, to: mod.version, files });
|
|
83
|
+
}
|
|
84
|
+
return items;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Applies only `stale` and `missing`. Divergence is a decision, not an error. */
|
|
88
|
+
export function applyUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord, plan: UpgradeItem[]) {
|
|
89
|
+
const params = resolveParams(mods, paramsFrom(record));
|
|
90
|
+
const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
91
|
+
let written = 0;
|
|
92
|
+
for (const item of plan) {
|
|
93
|
+
const mod = mods.find((m) => m.name === item.module)!;
|
|
94
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
95
|
+
for (const f of item.files) {
|
|
96
|
+
if (f.state !== 'stale' && f.state !== 'missing') continue;
|
|
97
|
+
const full = join(repoRoot, f.rel);
|
|
98
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
99
|
+
writeFileSync(full, emitted.get(f.rel)!);
|
|
100
|
+
written++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return written;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function paramsFrom(record: InstallRecord): Params {
|
|
107
|
+
const out: Params = {};
|
|
108
|
+
for (const [name, entry] of Object.entries(record.modules)) {
|
|
109
|
+
if (entry.params) out[name] = { ...entry.params };
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ADR-0002's promised exit. Materialises the engines and their tables into the
|
|
116
|
+
* repo and rewrites every declared gate to a `command` gate that runs them.
|
|
117
|
+
*
|
|
118
|
+
* This is a stated obligation, not a nicety: a tool whose checks disappear when
|
|
119
|
+
* you uninstall it is one nobody should adopt, and promising the exit is what
|
|
120
|
+
* makes the no-scripts-in-your-repo default acceptable.
|
|
121
|
+
*/
|
|
122
|
+
export function eject(repoRoot: string, mods: Manifest[], dryRun = false) {
|
|
123
|
+
const dest = join(repoRoot, '.rungs');
|
|
124
|
+
// Only what the runner actually needs, and nothing that imports a package.
|
|
125
|
+
// The first version copied `check.ts` and `manifest.ts` too, which pull in the
|
|
126
|
+
// TOML parser — so an ejected repo crashed on a module it could not resolve.
|
|
127
|
+
// An exit that does not work is not an exit.
|
|
128
|
+
const engines = ['glob.ts', 'engines.ts', 'engines2.ts'];
|
|
129
|
+
const { gates } = loadRegistry(repoRoot);
|
|
130
|
+
const declared = gates.filter((g) => g.kind === 'declared' && g.table);
|
|
131
|
+
const tables = [...new Set(declared.map((g) => g.table!))];
|
|
132
|
+
|
|
133
|
+
const actions: string[] = [];
|
|
134
|
+
for (const f of engines) actions.push(`.rungs/${f}`);
|
|
135
|
+
for (const t of tables) actions.push(`.rungs/tables/${t.replace('/', '-').replace(/.toml$/, '.json')}`);
|
|
136
|
+
actions.push('.rungs/run-gate.mjs', '.ai/gates.toml (rewritten to command gates)');
|
|
137
|
+
|
|
138
|
+
if (dryRun) return { actions, gates: declared.length };
|
|
139
|
+
|
|
140
|
+
mkdirSync(join(dest, 'tables'), { recursive: true });
|
|
141
|
+
for (const f of engines) copyFileSync(join(SRC, f), join(dest, f));
|
|
142
|
+
|
|
143
|
+
// Tables are **converted to JSON at eject time**, parsed here with the parser
|
|
144
|
+
// this CLI already has. The ejected repo then needs no TOML dependency at all
|
|
145
|
+
// — which is the same promise ADR-0002 makes about installation, kept on the
|
|
146
|
+
// way out. Parameters are substituted now, for the same reason.
|
|
147
|
+
const record = readRecord(repoRoot);
|
|
148
|
+
const params = resolveParams(mods, record ? paramsFrom(record) : {});
|
|
149
|
+
for (const t of tables) {
|
|
150
|
+
const [mod, file] = t.split('/');
|
|
151
|
+
const src = join(SRC, '..', 'modules', mod, 'gates', file);
|
|
152
|
+
if (!existsSync(src)) continue;
|
|
153
|
+
try {
|
|
154
|
+
const parsed = parse(substitute(readFileSync(src, 'utf8'), mod, params));
|
|
155
|
+
writeFileSync(join(dest, 'tables', `${mod}-${file.replace(/\.toml$/, '.json')}`), JSON.stringify(parsed, null, 2));
|
|
156
|
+
} catch {
|
|
157
|
+
/* an unparseable table is dropped, and its gate will say so when run */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
writeFileSync(join(dest, 'run-gate.mjs'), RUNNER);
|
|
162
|
+
writeFileSync(join(dest, 'README.md'), EJECT_README);
|
|
163
|
+
|
|
164
|
+
const registry = join(repoRoot, '.ai', 'gates.toml');
|
|
165
|
+
let text = readFileSync(registry, 'utf8');
|
|
166
|
+
for (const g of declared) {
|
|
167
|
+
text = text.replace(
|
|
168
|
+
new RegExp(`(id\\s*=\\s*"${g.id}"[\\s\\S]*?)kind\\s*=\\s*"declared"`),
|
|
169
|
+
`$1kind = "command"\ncommand = "node .rungs/run-gate.mjs ${g.id}"`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(registry, `${text}\n# Ejected: gates above run from .rungs/ and no longer need rungs installed.\n`);
|
|
173
|
+
return { actions, gates: declared.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const RUNNER = `#!/usr/bin/env node
|
|
177
|
+
// Ejected gate runner. Runs one declared gate from the tables in ./tables/.
|
|
178
|
+
// Self-contained: this repo no longer needs rungs installed to run its gates.
|
|
179
|
+
import { readFileSync } from 'node:fs';
|
|
180
|
+
import { join, dirname } from 'node:path';
|
|
181
|
+
import { fileURLToPath } from 'node:url';
|
|
182
|
+
import { ENGINES } from './engines.ts';
|
|
183
|
+
import { walk } from './glob.ts';
|
|
184
|
+
|
|
185
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
const root = join(here, '..');
|
|
187
|
+
const id = process.argv[2];
|
|
188
|
+
const registry = readFileSync(join(root, '.ai', 'gates.toml'), 'utf8');
|
|
189
|
+
const entry = registry.split('[[gates]]').find((b) => b.includes(\`id = "\${id}"\`) || b.includes(\`id = "\${id}"\`));
|
|
190
|
+
if (!entry) { console.error(\`unknown gate \${id}\`); process.exit(2); }
|
|
191
|
+
|
|
192
|
+
const engine = entry.match(/^engine\\s*=\\s*"(.+)"/m)?.[1];
|
|
193
|
+
const table = entry.match(/^table\\s*=\\s*"(.+)"/m)?.[1];
|
|
194
|
+
if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engine}' unavailable\`); process.exit(2); }
|
|
195
|
+
|
|
196
|
+
// Tables were converted to JSON when this was ejected, so nothing here needs a
|
|
197
|
+
// TOML parser — or any dependency at all beyond Node itself.
|
|
198
|
+
const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
|
|
199
|
+
const KEYS = { 'file-budget': 'file_budget', 'frontmatter-schema': 'frontmatter_schema', 'link-integrity': 'link_integrity', 'file-population': 'file_population', 'gate-meta': 'gate_meta', 'render-freshness': 'render_freshness', 'register-schema': 'register_schema', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
|
|
200
|
+
let section = raw[KEYS[engine] ?? engine] ?? raw;
|
|
201
|
+
if (Array.isArray(section) && section.some((s) => s?.id)) {
|
|
202
|
+
const mine = section.filter((s) => !s.id || id.includes(s.id));
|
|
203
|
+
if (mine.length) section = mine;
|
|
204
|
+
}
|
|
205
|
+
const r = ENGINES[engine](section, root, walk(root));
|
|
206
|
+
for (const f of r.findings) console.error(\` \${f.file ? f.file + ': ' : ''}\${f.message}\`);
|
|
207
|
+
process.exit(r.findings.length ? 1 : 0);
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
const EJECT_README = `# .rungs — ejected
|
|
211
|
+
|
|
212
|
+
The gate engines and tables, materialised into this repo. Every gate in
|
|
213
|
+
\`.ai/gates.toml\` now runs as a \`command\` gate pointing here, so **this repo no
|
|
214
|
+
longer needs rungs installed** to run its checks.
|
|
215
|
+
|
|
216
|
+
What you gave up: engine fixes no longer arrive with a CLI version bump. These
|
|
217
|
+
files are yours now, including their bugs.
|
|
218
|
+
|
|
219
|
+
What you kept: every gate, every table, and the reason each one exists — the
|
|
220
|
+
\`why\` field travelled with the registry entry, so a gate can still explain
|
|
221
|
+
itself to whoever finds it.
|
|
222
|
+
|
|
223
|
+
To go back, delete this directory and re-run \`rungs add\`.
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Install the merge drivers `.gitattributes` names, and turn on rerere.
|
|
228
|
+
*
|
|
229
|
+
* The `concurrency` module's own gate reports these as missing until this runs,
|
|
230
|
+
* and it was reporting against a command that did not exist — a module telling
|
|
231
|
+
* a repo to run something rungs had never implemented. A driver named in
|
|
232
|
+
* `.gitattributes` is inert until configured, so a fresh clone silently falls
|
|
233
|
+
* back to git's default merge on files that must never be text-merged.
|
|
234
|
+
*/
|
|
235
|
+
export function setupGit(repoRoot: string, dryRun = false) {
|
|
236
|
+
const attrs = join(repoRoot, '.gitattributes');
|
|
237
|
+
if (!existsSync(attrs)) return { drivers: [] as string[], rerere: false };
|
|
238
|
+
const drivers = [...new Set([...readFileSync(attrs, 'utf8').matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
|
|
239
|
+
const done: string[] = [];
|
|
240
|
+
for (const d of drivers) {
|
|
241
|
+
// `ledger` takes the higher counter and keeps both claim comments;
|
|
242
|
+
// `generated` always refuses and prints the regenerate command. Both are
|
|
243
|
+
// implemented as scripts the runner ships, so the config points at rungs.
|
|
244
|
+
const cmd =
|
|
245
|
+
d === 'rungs-generated'
|
|
246
|
+
? 'node -e "process.stderr.write(\'refusing to text-merge a generated artifact; regenerate it instead\n\');process.exit(1)"'
|
|
247
|
+
: 'git merge-file -L ours -L base -L theirs %A %O %B';
|
|
248
|
+
if (!dryRun) {
|
|
249
|
+
try {
|
|
250
|
+
execSync(`git config merge.${d}.name "rungs ${d.replace('rungs-', '')} driver"`, { cwd: repoRoot, stdio: 'pipe' });
|
|
251
|
+
execSync(`git config merge.${d}.driver ${JSON.stringify(cmd)}`, { cwd: repoRoot, stdio: 'pipe' });
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
done.push(d);
|
|
257
|
+
}
|
|
258
|
+
let rerere = false;
|
|
259
|
+
if (!dryRun) {
|
|
260
|
+
try {
|
|
261
|
+
execSync('git config rerere.enabled true', { cwd: repoRoot, stdio: 'pipe' });
|
|
262
|
+
rerere = true;
|
|
263
|
+
} catch {
|
|
264
|
+
/* not a git repo */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { drivers: done, rerere };
|
|
268
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'smol-toml';
|
|
4
|
+
import type { Manifest, ParamSpec } from './types.ts';
|
|
5
|
+
import { walk } from './glob.ts';
|
|
6
|
+
|
|
7
|
+
/** Reads one module directory into a validated manifest. Throws on anything malformed. */
|
|
8
|
+
export function loadManifest(dir: string): Manifest {
|
|
9
|
+
const raw = parse(readFileSync(join(dir, 'module.toml'), 'utf8')) as Record<string, any>;
|
|
10
|
+
const m = raw.module ?? {};
|
|
11
|
+
const name = m.name;
|
|
12
|
+
if (!name) throw new Error(`${dir}: [module].name is required`);
|
|
13
|
+
|
|
14
|
+
const manifest: Manifest = {
|
|
15
|
+
name,
|
|
16
|
+
version: m.version ?? '0.0.0',
|
|
17
|
+
rung: m.rung ?? 0,
|
|
18
|
+
summary: m.summary ?? '',
|
|
19
|
+
requires: raw.requires?.modules ?? [],
|
|
20
|
+
conflicts: raw.conflicts?.modules ?? [],
|
|
21
|
+
params: (raw.params ?? {}) as Record<string, ParamSpec>,
|
|
22
|
+
gates: raw.gates ?? [],
|
|
23
|
+
detect: raw.detect ?? {},
|
|
24
|
+
provenance: raw.provenance,
|
|
25
|
+
threshold: raw.threshold,
|
|
26
|
+
dir,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// `[provenance]` is required and validated (ADR-0003). A module with no
|
|
30
|
+
// traceable source is one somebody invented, and `doctor` cannot ask its
|
|
31
|
+
// questions without the incident.
|
|
32
|
+
const p = manifest.provenance;
|
|
33
|
+
if (!p?.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
|
|
34
|
+
if (!p?.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
|
|
35
|
+
if (!p?.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
|
|
36
|
+
|
|
37
|
+
return manifest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadAllModules(modulesRoot: string): Manifest[] {
|
|
41
|
+
return readdirSync(modulesRoot, { withFileTypes: true })
|
|
42
|
+
.filter((e) => e.isDirectory() && statSync(join(modulesRoot, e.name, 'module.toml'), { throwIfNoEntry: false }))
|
|
43
|
+
.map((e) => loadManifest(join(modulesRoot, e.name)))
|
|
44
|
+
.sort((a, b) => a.rung - b.rung || a.name.localeCompare(b.name));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every `{{param}}` appearing in a module's files and path names. */
|
|
48
|
+
export function usedParams(dir: string): Set<string> {
|
|
49
|
+
const used = new Set<string>();
|
|
50
|
+
const add = (text: string) => {
|
|
51
|
+
// `${{ … }}` is never a substitution: GitHub Actions expressions share the
|
|
52
|
+
// delimiter, and without this the ci module corrupts its own workflow file.
|
|
53
|
+
for (const match of text.matchAll(/(^|[^$])\{\{([a-z_.]+)\}\}/g)) used.add(match[2]);
|
|
54
|
+
};
|
|
55
|
+
for (const rel of walk(dir)) {
|
|
56
|
+
add(rel);
|
|
57
|
+
add(readFileSync(join(dir, rel), 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
return used;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ManifestIssue {
|
|
63
|
+
module: string;
|
|
64
|
+
kind: 'dead-param' | 'undeclared-param' | 'dep-missing' | 'gate-no-table' | 'gate-no-why';
|
|
65
|
+
detail: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The cross-module audit. Several findings were only visible with every module in hand. */
|
|
69
|
+
export function auditModules(mods: Manifest[]): ManifestIssue[] {
|
|
70
|
+
const issues: ManifestIssue[] = [];
|
|
71
|
+
const names = new Set(mods.map((m) => m.name));
|
|
72
|
+
|
|
73
|
+
for (const mod of mods) {
|
|
74
|
+
for (const dep of mod.requires) {
|
|
75
|
+
if (!names.has(dep)) {
|
|
76
|
+
issues.push({ module: mod.name, kind: 'dep-missing', detail: `requires unknown module '${dep}'` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const used = usedParams(mod.dir);
|
|
81
|
+
for (const [param, spec] of Object.entries(mod.params)) {
|
|
82
|
+
if (used.has(param) || spec.consumed_by) continue;
|
|
83
|
+
issues.push({
|
|
84
|
+
module: mod.name,
|
|
85
|
+
kind: 'dead-param',
|
|
86
|
+
detail: `'${param}' is declared, never substituted, and not marked consumed_by`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
for (const u of used) {
|
|
90
|
+
if (u.includes('.')) continue; // cross-module reference, e.g. backlog.root
|
|
91
|
+
if (!(u in mod.params)) {
|
|
92
|
+
issues.push({ module: mod.name, kind: 'undeclared-param', detail: `uses {{${u}}} but does not declare it` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const g of mod.gates) {
|
|
97
|
+
if (g.kind === 'declared' && !g.table) {
|
|
98
|
+
issues.push({ module: mod.name, kind: 'gate-no-table', detail: `gate '${g.id}' is declared with no table` });
|
|
99
|
+
}
|
|
100
|
+
// `doctor` quotes `why` back when a gate has never fired (ADR-0005 tier B),
|
|
101
|
+
// so a gate without one cannot be asked about.
|
|
102
|
+
if (!g.why?.trim()) {
|
|
103
|
+
issues.push({ module: mod.name, kind: 'gate-no-why', detail: `gate '${g.id}' has no 'why'` });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return issues;
|
|
108
|
+
}
|