@rungs/cli 0.3.1 → 0.4.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 +6 -6
- package/dist/cli.js +2184 -478
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +18 -29
- package/src/engines2.ts +403 -20
- package/src/engines3.ts +111 -20
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +86 -27
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- package/src/version-source.ts +144 -0
package/src/help.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command surface, defined once and rendered into `--help`.
|
|
3
|
+
*
|
|
4
|
+
* It was a template literal listing eight of the nine commands — `setup git` was missing entirely —
|
|
5
|
+
* beside a README table listing all nine, which is two hand-kept inventories of one fact. They had
|
|
6
|
+
* already drifted, in both directions: help omitted a real command, and three real flags appeared
|
|
7
|
+
* in neither. Keep this dependency-free: the docs claim gate imports this exact authority in a
|
|
8
|
+
* fresh land worktree where ignored package dependencies are deliberately absent.
|
|
9
|
+
*
|
|
10
|
+
* The README's table is still hand-kept and still a second inventory. That is a known cost, not an
|
|
11
|
+
* oversight — see WI-004.
|
|
12
|
+
*/
|
|
13
|
+
export const COMMANDS: [usage: string, blurb: string][] = [
|
|
14
|
+
['init [path] [profile]', 'scaffold a repo — minimal · tracked · disciplined · hardened · fleet'],
|
|
15
|
+
['doctor [path]', 'detect what a repo already has, installed or not'],
|
|
16
|
+
['add <module…> [--into p]', 'install modules, resolving dependencies and adopting what exists'],
|
|
17
|
+
['check [path] [tier]', 'run the registered gates and record the ledger'],
|
|
18
|
+
['render [path]', 're-emit path-scoped rules per harness'],
|
|
19
|
+
['upgrade [path]', 'move to newer module versions, never touching what you edited'],
|
|
20
|
+
['eject [path]', 'materialise the engines; stop depending on rungs'],
|
|
21
|
+
['setup git [path]', 'install the merge drivers .gitattributes names'],
|
|
22
|
+
['modules', 'list the module set and audit the manifests'],
|
|
23
|
+
['backlog archive [path]', 'move finished items to archive/, repointing every link'],
|
|
24
|
+
['session start <branch>', 'cut a branch and worktree from the last verified merge'],
|
|
25
|
+
['preflight [path]', 'did the integration branch change files you changed?'],
|
|
26
|
+
['land <branch>', 'merge → verify the merged tree → advance, or refuse and park it'],
|
|
27
|
+
['worktrees [path]', 'which worktrees are merged, prunable, or merged and still dirty'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Every flag the parser honours. A flag absent here is a flag nobody can find. */
|
|
31
|
+
export const FLAGS: [flag: string, blurb: string][] = [
|
|
32
|
+
['--dry-run', 'report what would happen, write nothing'],
|
|
33
|
+
['--explain', "doctor: also run the detectors over what this repo already has"],
|
|
34
|
+
['--confirm-paradigm', 'add: install a module this repo already solves another way'],
|
|
35
|
+
['--confirm-conflict', 'add: install a module that declares a conflict with one already here'],
|
|
36
|
+
['--into <path>', 'add: install into this repo instead of the working directory'],
|
|
37
|
+
['--set m.param=value', 'add/init: override a module parameter. Repeatable'],
|
|
38
|
+
['--confirm-threshold', 'add: install a module whose rung is above this repo'],
|
|
39
|
+
['--apply', 'upgrade: write the changes, rather than preview them'],
|
|
40
|
+
['--fast, --full', 'check: pick the gate tier, as the positional also does'],
|
|
41
|
+
['--params', 'modules: show every module parameter, its default and its allowed values'],
|
|
42
|
+
['--copilot', 'also emit Copilot instruction files'],
|
|
43
|
+
];
|
package/src/lifecycle.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { parse } from 'smol-toml';
|
|
6
6
|
import type { Manifest } from './types.ts';
|
|
7
|
-
import { contentHash, emittedFiles, registerGates } from './add.ts';
|
|
7
|
+
import { contentHash, emittedFiles, moduleEmissionCandidates, preflightModuleEmissions, registerGates } from './add.ts';
|
|
8
8
|
import { resolveParams, substitute, type Params } from './substitute.ts';
|
|
9
9
|
import { loadRegistry } from './check.ts';
|
|
10
|
+
import { preflightEmittedPaths, resolveEmittedPath, UnsafeEmittedPathError } from './emitted-path.ts';
|
|
11
|
+
import { semanticText } from './text.ts';
|
|
10
12
|
|
|
11
13
|
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
12
14
|
|
|
@@ -25,7 +27,15 @@ export interface InstallRecord {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function readRecord(repoRoot: string): InstallRecord | null {
|
|
28
|
-
const
|
|
30
|
+
const resolved = resolveEmittedPath(repoRoot, 'rungs', '.ai/rungs.toml');
|
|
31
|
+
if (resolved.leafAlias) {
|
|
32
|
+
throw new UnsafeEmittedPathError(
|
|
33
|
+
'rungs',
|
|
34
|
+
'.ai/rungs.toml',
|
|
35
|
+
'the install record is a symlink or junction leaf and will not be used as configuration',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const p = resolved.absolute;
|
|
29
39
|
if (!existsSync(p)) return null;
|
|
30
40
|
try {
|
|
31
41
|
const raw = parse(readFileSync(p, 'utf8')) as any;
|
|
@@ -59,16 +69,26 @@ export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallR
|
|
|
59
69
|
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
60
70
|
const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
61
71
|
const items: UpgradeItem[] = [];
|
|
72
|
+
const installedMods = mods.filter((mod) => record.modules[mod.name]);
|
|
73
|
+
|
|
74
|
+
// Parameters in the install record are untrusted input to this newer CLI.
|
|
75
|
+
// Validate every installed module before examining any one of them, so a
|
|
76
|
+
// later unsafe target cannot make a partial plan look usable.
|
|
77
|
+
preflightModuleEmissions(installedMods, repoRoot, params, skillsDir);
|
|
62
78
|
|
|
63
|
-
for (const mod of
|
|
79
|
+
for (const mod of installedMods) {
|
|
64
80
|
const installed = record.modules[mod.name];
|
|
65
|
-
if (!installed) continue;
|
|
66
81
|
const emitted = emittedFiles(mod, params, skillsDir);
|
|
67
82
|
const files: UpgradeItem['files'] = [];
|
|
68
83
|
const kept = new Set(installed.kept?.files ?? []);
|
|
69
84
|
for (const [rel, wouldEmit] of emitted) {
|
|
70
85
|
if (kept.has(rel)) continue; // never ours; upgrade does not touch it
|
|
71
|
-
const
|
|
86
|
+
const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
|
|
87
|
+
const full = resolved.absolute;
|
|
88
|
+
if (resolved.leafAlias) {
|
|
89
|
+
files.push({ rel, state: 'diverged' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
72
92
|
if (!existsSync(full)) {
|
|
73
93
|
files.push({ rel, state: 'missing' });
|
|
74
94
|
continue;
|
|
@@ -88,21 +108,58 @@ export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallR
|
|
|
88
108
|
export function applyUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord, plan: UpgradeItem[]) {
|
|
89
109
|
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
90
110
|
const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
111
|
+
const prepared = plan.map((item) => {
|
|
112
|
+
const mod = mods.find((candidate) => candidate.name === item.module);
|
|
113
|
+
if (!mod) throw new Error(`upgrade plan names unknown module '${item.module}'`);
|
|
114
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
115
|
+
const files = item.files.map((file) => {
|
|
116
|
+
const resolved = resolveEmittedPath(repoRoot, mod.name, file.rel);
|
|
117
|
+
if ((file.state === 'stale' || file.state === 'missing') && resolved.leafAlias) {
|
|
118
|
+
throw new UnsafeEmittedPathError(
|
|
119
|
+
mod.name,
|
|
120
|
+
file.rel,
|
|
121
|
+
'the destination is a symlink or junction leaf and upgrade will not write through it',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if ((file.state === 'stale' || file.state === 'missing') && !emitted.has(resolved.target)) {
|
|
125
|
+
throw new Error(`module '${mod.name}' upgrade plan names target '${file.rel}' that the module does not emit`);
|
|
126
|
+
}
|
|
127
|
+
return { ...file, target: resolved.target, absolute: resolved.absolute };
|
|
128
|
+
});
|
|
129
|
+
return { item, mod, emitted, files };
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Rebuild and validate the complete emission set at apply time. Callers may
|
|
133
|
+
// retain or manufacture a plan, so application never trusts planning to have
|
|
134
|
+
// happened in this process or against this filesystem.
|
|
135
|
+
const writable = new Set(
|
|
136
|
+
prepared.flatMap(({ mod, files }) =>
|
|
137
|
+
files
|
|
138
|
+
.filter((file) => file.state === 'stale' || file.state === 'missing')
|
|
139
|
+
.map((file) => `${mod.name}\0${file.target}`),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
preflightEmittedPaths(repoRoot, [
|
|
143
|
+
...moduleEmissionCandidates(prepared.map(({ mod }) => mod), params, skillsDir).map((candidate) =>
|
|
144
|
+
writable.has(`${candidate.moduleName}\0${candidate.target}`)
|
|
145
|
+
? { ...candidate, writeExisting: true }
|
|
146
|
+
: candidate,
|
|
147
|
+
),
|
|
148
|
+
{ moduleName: prepared[0]?.mod.name ?? 'upgrade', target: '.ai/rungs.toml', writeExisting: true },
|
|
149
|
+
]);
|
|
150
|
+
|
|
91
151
|
let written = 0;
|
|
92
152
|
// Only files this run rewrote. A diverged file is not in here, which is what
|
|
93
153
|
// keeps its recorded hash — and therefore its protection — intact (F-017).
|
|
94
154
|
const rewritten = new Map<string, Map<string, string>>();
|
|
95
|
-
for (const
|
|
96
|
-
const
|
|
97
|
-
const emitted = emittedFiles(mod, params, skillsDir);
|
|
98
|
-
for (const f of item.files) {
|
|
155
|
+
for (const { mod, emitted, files } of prepared) {
|
|
156
|
+
for (const f of files) {
|
|
99
157
|
if (f.state !== 'stale' && f.state !== 'missing') continue;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
writeFileSync(full, content);
|
|
158
|
+
const content = emitted.get(f.target)!;
|
|
159
|
+
mkdirSync(dirname(f.absolute), { recursive: true });
|
|
160
|
+
writeFileSync(f.absolute, content);
|
|
104
161
|
if (!rewritten.has(mod.name)) rewritten.set(mod.name, new Map());
|
|
105
|
-
rewritten.get(mod.name)!.set(f.
|
|
162
|
+
rewritten.get(mod.name)!.set(f.target, contentHash(content));
|
|
106
163
|
written++;
|
|
107
164
|
}
|
|
108
165
|
}
|
|
@@ -117,7 +174,7 @@ export function applyUpgrade(repoRoot: string, mods: Manifest[], record: Install
|
|
|
117
174
|
// dropped from a manifest leaves the registry with the block that replaces it.
|
|
118
175
|
// Idempotent, and cheap enough to run for every module in the plan rather than
|
|
119
176
|
// only the ones whose files happened to be stale.
|
|
120
|
-
const upgraded =
|
|
177
|
+
const upgraded = prepared.map(({ mod }) => mod);
|
|
121
178
|
const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
|
|
122
179
|
|
|
123
180
|
const recorded = updateRecordAfterUpgrade(
|
|
@@ -149,10 +206,14 @@ export function updateRecordAfterUpgrade(
|
|
|
149
206
|
repoRoot: string,
|
|
150
207
|
updates: { module: string; version: string; hashes: Map<string, string> }[],
|
|
151
208
|
): number {
|
|
152
|
-
const path =
|
|
209
|
+
const path = preflightEmittedPaths(repoRoot, [
|
|
210
|
+
{ moduleName: updates[0]?.module ?? 'upgrade', target: '.ai/rungs.toml', writeExisting: true },
|
|
211
|
+
])[0].absolute;
|
|
153
212
|
if (!existsSync(path) || !updates.length) return 0;
|
|
154
213
|
|
|
155
|
-
const
|
|
214
|
+
const original = readFileSync(path, 'utf8');
|
|
215
|
+
const newline = original.match(/\r\n|\r|\n/)?.[0] ?? '\n';
|
|
216
|
+
const lines = semanticText(original).split('\n');
|
|
156
217
|
const byModule = new Map(updates.map((u) => [u.module, u]));
|
|
157
218
|
let changed = 0;
|
|
158
219
|
let current: { module: string; hashes: boolean } | null = null;
|
|
@@ -177,8 +238,9 @@ export function updateRecordAfterUpgrade(
|
|
|
177
238
|
const entry = /^"([^"]+)"\s*=/.exec(line);
|
|
178
239
|
const replacement = entry && byModule.get(current.module)!.hashes.get(entry[1]);
|
|
179
240
|
if (replacement) {
|
|
180
|
-
|
|
181
|
-
changed++;
|
|
241
|
+
const next = `"${entry[1]}" = "${replacement}"`;
|
|
242
|
+
if (next !== line) changed++;
|
|
243
|
+
out.push(next);
|
|
182
244
|
continue;
|
|
183
245
|
}
|
|
184
246
|
}
|
|
@@ -186,7 +248,8 @@ export function updateRecordAfterUpgrade(
|
|
|
186
248
|
out.push(line);
|
|
187
249
|
}
|
|
188
250
|
|
|
189
|
-
|
|
251
|
+
const updated = out.join(newline);
|
|
252
|
+
if (changed > 0 && updated !== original) writeFileSync(path, updated);
|
|
190
253
|
return changed;
|
|
191
254
|
}
|
|
192
255
|
|
|
@@ -212,7 +275,7 @@ export function eject(repoRoot: string, mods: Manifest[], dryRun = false) {
|
|
|
212
275
|
// The first version copied `check.ts` and `manifest.ts` too, which pull in the
|
|
213
276
|
// TOML parser — so an ejected repo crashed on a module it could not resolve.
|
|
214
277
|
// An exit that does not work is not an exit.
|
|
215
|
-
const engines = ['glob.ts', 'engines.ts', 'engines2.ts'];
|
|
278
|
+
const engines = ['glob.ts', 'text.ts', 'engine-table.ts', 'engines.ts', 'engines2.ts'];
|
|
216
279
|
const { gates } = loadRegistry(repoRoot);
|
|
217
280
|
const declared = gates.filter((g) => g.kind === 'declared' && g.table);
|
|
218
281
|
const tables = [...new Set(declared.map((g) => g.table!))];
|
|
@@ -267,6 +330,7 @@ import { readFileSync } from 'node:fs';
|
|
|
267
330
|
import { join, dirname } from 'node:path';
|
|
268
331
|
import { fileURLToPath } from 'node:url';
|
|
269
332
|
import { ENGINES } from './engines.ts';
|
|
333
|
+
import { selectEngineTable } from './engine-table.ts';
|
|
270
334
|
import { walk } from './glob.ts';
|
|
271
335
|
|
|
272
336
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -283,12 +347,7 @@ if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engin
|
|
|
283
347
|
// Tables were converted to JSON when this was ejected, so nothing here needs a
|
|
284
348
|
// TOML parser — or any dependency at all beyond Node itself.
|
|
285
349
|
const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
|
|
286
|
-
const
|
|
287
|
-
let section = raw[KEYS[engine] ?? engine] ?? raw;
|
|
288
|
-
if (Array.isArray(section) && section.some((s) => s?.id)) {
|
|
289
|
-
const mine = section.filter((s) => !s.id || id.includes(s.id));
|
|
290
|
-
if (mine.length) section = mine;
|
|
291
|
-
}
|
|
350
|
+
const section = selectEngineTable(raw, engine, id);
|
|
292
351
|
const r = ENGINES[engine](section, root, walk(root));
|
|
293
352
|
for (const f of r.findings) console.error(\` \${f.file ? f.file + ': ' : ''}\${f.message}\`);
|
|
294
353
|
process.exit(r.findings.length ? 1 : 0);
|
package/src/manifest.ts
CHANGED
|
@@ -28,12 +28,48 @@ export function loadManifest(dir: string): Manifest {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
// `[provenance]` is required and validated (ADR-0003). A module with no
|
|
31
|
-
// traceable source is one somebody invented, and
|
|
32
|
-
//
|
|
31
|
+
// traceable source is one somebody invented, and that is the thing CLAUDE.md's
|
|
32
|
+
// evidence rule exists to prevent.
|
|
33
|
+
//
|
|
34
|
+
// F-037: the schema had exactly one shape, and it *asserted extraction* —
|
|
35
|
+
// `sources`, `patterns` and `incident`, all required. A module authored
|
|
36
|
+
// outside this package has none of the three, and the only way past the
|
|
37
|
+
// validator was prose in fields whose names claim a pedigree it does not
|
|
38
|
+
// have. It never blocked anyone: any non-empty string loads, which is the
|
|
39
|
+
// worse outcome, because nothing then distinguished an honest "none" from an
|
|
40
|
+
// invented incident. All fifteen bundled modules were extracted, so the case
|
|
41
|
+
// had never been exercised and the first author outside them was quietly
|
|
42
|
+
// invited to make something up.
|
|
43
|
+
//
|
|
44
|
+
// So the distinction is declared rather than written around, and a designed
|
|
45
|
+
// module may not carry the extracted module's fields. **Half a claim is the
|
|
46
|
+
// failure mode**: a `sources` line naming the repo that inspired a module
|
|
47
|
+
// reads, to every later reader, exactly like a repo that paid for it.
|
|
33
48
|
const p = manifest.provenance;
|
|
34
|
-
if (!p
|
|
35
|
-
|
|
36
|
-
if (
|
|
49
|
+
if (!p) throw new Error(`${name}: [provenance] is required`);
|
|
50
|
+
const kind = p.kind ?? 'extracted';
|
|
51
|
+
if (kind !== 'extracted' && kind !== 'designed') {
|
|
52
|
+
throw new Error(`${name}: [provenance].kind must be 'extracted' or 'designed', not '${kind}'`);
|
|
53
|
+
}
|
|
54
|
+
if (kind === 'extracted') {
|
|
55
|
+
if (!p.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
|
|
56
|
+
if (!p.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
|
|
57
|
+
if (!p.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
|
|
58
|
+
} else {
|
|
59
|
+
if (!p.rationale?.trim()) {
|
|
60
|
+
throw new Error(`${name}: [provenance].rationale is required when kind = "designed" — say why it exists, in the first person`);
|
|
61
|
+
}
|
|
62
|
+
if (p.incident?.trim()) {
|
|
63
|
+
throw new Error(`${name}: [provenance].incident belongs to an extracted module; a designed one has no incident to name`);
|
|
64
|
+
}
|
|
65
|
+
if (p.sources?.length) {
|
|
66
|
+
throw new Error(`${name}: [provenance].sources belongs to an extracted module; cite influences in the rationale instead`);
|
|
67
|
+
}
|
|
68
|
+
// `patterns` is deliberately still allowed. A designed module may implement
|
|
69
|
+
// patterns from the catalogue, and the catalogue entries are themselves
|
|
70
|
+
// evidenced — citing one claims nothing about this module's own history.
|
|
71
|
+
}
|
|
72
|
+
manifest.provenance = { ...p, kind };
|
|
37
73
|
|
|
38
74
|
return manifest;
|
|
39
75
|
}
|
package/src/render.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { walk } from './glob.ts';
|
|
4
|
+
import { preflightEmittedPaths, resolveEmittedPath, type EmittedPathCandidate } from './emitted-path.ts';
|
|
5
|
+
import { semanticText } from './text.ts';
|
|
4
6
|
|
|
5
7
|
export type Harness = 'claude' | 'copilot' | 'cursor' | 'agents-md';
|
|
6
8
|
|
|
@@ -20,12 +22,18 @@ export interface RenderEntry {
|
|
|
20
22
|
dropped?: string[];
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export interface ProspectiveRule {
|
|
26
|
+
moduleName: string;
|
|
27
|
+
target: string;
|
|
28
|
+
content: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
const DO_NOT_EDIT = (source: string) =>
|
|
24
32
|
`Generated by \`rungs render\` from ${source}. Do not edit — your changes are overwritten.`;
|
|
25
33
|
|
|
26
34
|
/** Parse `.ai/rules/*.md`: the neutral source ADR-0001 renders from. */
|
|
27
35
|
export function readRules(repoRoot: string): Rule[] {
|
|
28
|
-
const dir =
|
|
36
|
+
const dir = resolveEmittedPath(repoRoot, 'render', '.ai/rules').absolute;
|
|
29
37
|
const rules: Rule[] = [];
|
|
30
38
|
let files: string[];
|
|
31
39
|
try {
|
|
@@ -34,21 +42,26 @@ export function readRules(repoRoot: string): Rule[] {
|
|
|
34
42
|
return rules;
|
|
35
43
|
}
|
|
36
44
|
for (const rel of files) {
|
|
37
|
-
const raw = readFileSync(join(dir, rel), 'utf8');
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
const [, fm, body] = m;
|
|
41
|
-
rules.push({
|
|
42
|
-
file: rel,
|
|
43
|
-
description: scalar(fm, 'description'),
|
|
44
|
-
paths: list(fm, 'paths'),
|
|
45
|
-
enforcement: scalar(fm, 'enforcement'),
|
|
46
|
-
body: body.trim(),
|
|
47
|
-
});
|
|
45
|
+
const raw = semanticText(readFileSync(join(dir, rel), 'utf8'));
|
|
46
|
+
const rule = parseRule(rel, raw);
|
|
47
|
+
if (rule) rules.push(rule);
|
|
48
48
|
}
|
|
49
49
|
return rules;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function parseRule(file: string, raw: string): Rule | null {
|
|
53
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const [, fm, body] = m;
|
|
56
|
+
return {
|
|
57
|
+
file,
|
|
58
|
+
description: scalar(fm, 'description'),
|
|
59
|
+
paths: list(fm, 'paths'),
|
|
60
|
+
enforcement: scalar(fm, 'enforcement'),
|
|
61
|
+
body: body.trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
function scalar(fm: string, key: string): string | undefined {
|
|
53
66
|
const folded = fm.match(new RegExp(`^${key}:\\s*>-?\\s*\\n([\\s\\S]*?)(?=\\n\\S|$)`, 'm'));
|
|
54
67
|
if (folded) return folded[1].split('\n').map((l) => l.trim()).filter(Boolean).join(' ');
|
|
@@ -127,10 +140,40 @@ function commonDirPrefix(paths: string[]): string | null {
|
|
|
127
140
|
return dirs.every((d) => d === first) && first.includes('/') ? first : null;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
|
|
143
|
+
interface PreparedRender {
|
|
144
|
+
entries: RenderEntry[];
|
|
145
|
+
outputs: { entry: RenderEntry; absolute: string; content: string }[];
|
|
146
|
+
routingOnly: Rule[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function prepareRender(
|
|
150
|
+
repoRoot: string,
|
|
151
|
+
harnesses: Harness[],
|
|
152
|
+
prospective: ProspectiveRule[] = [],
|
|
153
|
+
preceding: EmittedPathCandidate[] = [],
|
|
154
|
+
): PreparedRender {
|
|
131
155
|
const rules = readRules(repoRoot);
|
|
156
|
+
const owners = new Map(rules.map((rule) => [rule.file, 'render']));
|
|
157
|
+
const occupied = new Set(rules.map((rule) => `.ai/rules/${rule.file}`));
|
|
158
|
+
|
|
159
|
+
// Model the installer's no-overwrite rule: an existing source remains the
|
|
160
|
+
// one rendered. New prospective rules are considered in module order, just
|
|
161
|
+
// as addModule will create the first and keep any later collision.
|
|
162
|
+
for (const pending of prospective) {
|
|
163
|
+
const source = resolveEmittedPath(repoRoot, pending.moduleName, pending.target);
|
|
164
|
+
if (occupied.has(source.target) || existsSync(source.absolute)) continue;
|
|
165
|
+
occupied.add(source.target);
|
|
166
|
+
if (!source.target.startsWith('.ai/rules/')) continue;
|
|
167
|
+
const file = source.target.slice('.ai/rules/'.length);
|
|
168
|
+
const rule = parseRule(file, pending.content);
|
|
169
|
+
if (!rule) continue;
|
|
170
|
+
rules.push(rule);
|
|
171
|
+
owners.set(file, pending.moduleName);
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
const entries: RenderEntry[] = [];
|
|
133
175
|
const routingOnly: Rule[] = [];
|
|
176
|
+
const planned: { entry: RenderEntry; target: string; content: string; owner: string }[] = [];
|
|
134
177
|
|
|
135
178
|
for (const rule of rules) {
|
|
136
179
|
for (const harness of harnesses) {
|
|
@@ -140,23 +183,62 @@ export function render(repoRoot: string, harnesses: Harness[]): RenderEntry[] {
|
|
|
140
183
|
if (harness === 'agents-md') routingOnly.push(rule);
|
|
141
184
|
continue;
|
|
142
185
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
entries.push({ rule: rule.file, harness, target: out.target, dropped: out.dropped });
|
|
186
|
+
const entry = { rule: rule.file, harness, target: out.target, dropped: out.dropped };
|
|
187
|
+
entries.push(entry);
|
|
188
|
+
planned.push({ entry, target: out.target, content: out.content, owner: owners.get(rule.file) ?? 'render' });
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
|
|
192
|
+
const candidates = [
|
|
193
|
+
...preceding,
|
|
194
|
+
...planned.map((out) => ({ moduleName: out.owner, target: out.target, writeExisting: true })),
|
|
195
|
+
...(harnesses.includes('agents-md')
|
|
196
|
+
? [{ moduleName: 'render', target: 'AGENTS.md', shared: true, writeExisting: true }]
|
|
197
|
+
: []),
|
|
198
|
+
{ moduleName: 'render', target: '.ai/render-report.md', writeExisting: true },
|
|
199
|
+
];
|
|
200
|
+
const resolved = preflightEmittedPaths(repoRoot, candidates);
|
|
201
|
+
return {
|
|
202
|
+
entries,
|
|
203
|
+
outputs: planned.map((out, index) => ({
|
|
204
|
+
entry: out.entry,
|
|
205
|
+
content: out.content,
|
|
206
|
+
absolute: resolved[preceding.length + index].absolute,
|
|
207
|
+
})),
|
|
208
|
+
routingOnly,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Validate all current and would-be post-install render outputs without writing. */
|
|
213
|
+
export function preflightRender(
|
|
214
|
+
repoRoot: string,
|
|
215
|
+
harnesses: Harness[],
|
|
216
|
+
prospective: ProspectiveRule[] = [],
|
|
217
|
+
preceding: EmittedPathCandidate[] = [],
|
|
218
|
+
): void {
|
|
219
|
+
prepareRender(repoRoot, harnesses, prospective, preceding);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function render(repoRoot: string, harnesses: Harness[]): RenderEntry[] {
|
|
223
|
+
const prepared = prepareRender(repoRoot, harnesses);
|
|
224
|
+
|
|
225
|
+
for (const output of prepared.outputs) {
|
|
226
|
+
mkdirSync(dirname(output.absolute), { recursive: true });
|
|
227
|
+
writeFileSync(output.absolute, output.content);
|
|
228
|
+
}
|
|
229
|
+
|
|
150
230
|
// The report said root AGENTS.md "gets a pointer" and nothing wrote one — a
|
|
151
231
|
// degradation notice that was itself a silent drop, in the function whose
|
|
152
232
|
// whole job is not to have those. Written now, as a managed block.
|
|
153
|
-
writeRoutingBlock(repoRoot, routingOnly, harnesses);
|
|
154
|
-
return entries;
|
|
233
|
+
writeRoutingBlock(repoRoot, prepared.routingOnly, harnesses);
|
|
234
|
+
return prepared.entries;
|
|
155
235
|
}
|
|
156
236
|
|
|
157
237
|
function writeRoutingBlock(repoRoot: string, rules: Rule[], harnesses: Harness[]) {
|
|
158
238
|
if (!harnesses.includes('agents-md')) return;
|
|
159
|
-
const target =
|
|
239
|
+
const target = preflightEmittedPaths(repoRoot, [
|
|
240
|
+
{ moduleName: 'render', target: 'AGENTS.md', shared: true, writeExisting: true },
|
|
241
|
+
])[0].absolute;
|
|
160
242
|
if (!existsSync(target)) return;
|
|
161
243
|
const begin = '<!-- rungs:begin rules-routing -->';
|
|
162
244
|
const end = '<!-- rungs:end rules-routing -->';
|
|
@@ -217,6 +299,9 @@ export function writeReport(repoRoot: string, entries: RenderEntry[], harnesses:
|
|
|
217
299
|
'',
|
|
218
300
|
);
|
|
219
301
|
const content = lines.join('\n');
|
|
220
|
-
|
|
302
|
+
const target = preflightEmittedPaths(repoRoot, [
|
|
303
|
+
{ moduleName: 'render', target: '.ai/render-report.md', writeExisting: true },
|
|
304
|
+
])[0].absolute;
|
|
305
|
+
writeFileSync(target, content);
|
|
221
306
|
return content;
|
|
222
307
|
}
|
package/src/selftest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { tmpdir } from 'node:os';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { ENGINES, type Finding } from './engines.ts';
|
|
@@ -68,6 +69,49 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
68
69
|
if (typeof input === 'string') return [write(targetPath(table), `${input}\n`)];
|
|
69
70
|
if (!fx || typeof fx !== 'object') return null;
|
|
70
71
|
|
|
72
|
+
// A branch delta plus the companion files it carries. Unlike content-only
|
|
73
|
+
// fixtures this needs a real repository: the engine deliberately observes
|
|
74
|
+
// committed, staged, unstaged and untracked Git state rather than trusting a
|
|
75
|
+
// fixture's list as the answer.
|
|
76
|
+
if (Array.isArray(fx.changed) && Array.isArray(fx.fragments)) {
|
|
77
|
+
const git = (...args: string[]) =>
|
|
78
|
+
execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
79
|
+
const changed = fx.changed.map(String);
|
|
80
|
+
git('init', '-q', '-b', 'main', '.');
|
|
81
|
+
git('config', 'user.email', 'selftest@rungs.local');
|
|
82
|
+
git('config', 'user.name', 'rungs-selftest');
|
|
83
|
+
const written = [write('.fixture-base', 'base\n')];
|
|
84
|
+
if (changed.length && typeof fx.inherited_exempt === 'string') {
|
|
85
|
+
written.push(write(
|
|
86
|
+
changed[0],
|
|
87
|
+
`// ${fx.inherited_exempt}\nexport const fixtureState = 'base';\n`,
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
git('add', '--all');
|
|
91
|
+
git('commit', '-q', '-m', 'base');
|
|
92
|
+
git('switch', '-q', '-c', 'fixture/change');
|
|
93
|
+
|
|
94
|
+
for (const [index, rel] of changed.entries()) {
|
|
95
|
+
const evidence = index === 0 && typeof fx.exempt === 'string'
|
|
96
|
+
? fx.exempt
|
|
97
|
+
: index === 0 && typeof fx.inherited_exempt === 'string'
|
|
98
|
+
? fx.inherited_exempt
|
|
99
|
+
: undefined;
|
|
100
|
+
const body = evidence
|
|
101
|
+
? `// ${evidence}\nexport const fixtureState = 'branch';\n`
|
|
102
|
+
: 'fixture change\n';
|
|
103
|
+
written.push(write(rel, body));
|
|
104
|
+
}
|
|
105
|
+
const changelogDir = fx.dir ?? 'changelog.d';
|
|
106
|
+
for (const rel of fx.fragments) {
|
|
107
|
+
const concrete = String(rel).replace(/\{\{changelog_dir\}\}/g, changelogDir);
|
|
108
|
+
written.push(write(concrete, '# fixture fragment\n'));
|
|
109
|
+
}
|
|
110
|
+
git('add', '--all');
|
|
111
|
+
git('commit', '-q', '-m', 'fixture change');
|
|
112
|
+
return [...new Set(written)];
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
// A set of manifests and the version each states — the computed-claim shapes.
|
|
72
116
|
// `{ "package.json": "1.2.0", "site/package.json": "1.1.0" }`.
|
|
73
117
|
if (fx.packages && typeof fx.packages === 'object') {
|
|
@@ -76,6 +120,26 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
76
120
|
);
|
|
77
121
|
}
|
|
78
122
|
|
|
123
|
+
// Format-aware release-version sources. Values are rendered into the real
|
|
124
|
+
// source shape so one fixture can prove JSON, TOML and Directory.Build.props
|
|
125
|
+
// all participate in the same comparison.
|
|
126
|
+
if (fx.versions && typeof fx.versions === 'object') {
|
|
127
|
+
return Object.entries(fx.versions).map(([rel, version]) => {
|
|
128
|
+
const value = String(version).replace(/"/g, '\\"');
|
|
129
|
+
if (rel.endsWith('.toml')) return write(rel, `[project]\nversion = "${value}"\n`);
|
|
130
|
+
if (rel.endsWith('.props')) {
|
|
131
|
+
return write(rel, `<Project><PropertyGroup><Version>${String(version)}</Version></PropertyGroup></Project>\n`);
|
|
132
|
+
}
|
|
133
|
+
return write(rel, JSON.stringify({ name: rel.replace(/\W/g, '-'), version }));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Raw version-source fixtures preserve malformed documents and missing or
|
|
138
|
+
// non-scalar values exactly; normalising them would erase the failure under test.
|
|
139
|
+
if (fx.version_files && typeof fx.version_files === 'object') {
|
|
140
|
+
return Object.entries(fx.version_files).map(([rel, content]) => write(rel, String(content)));
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
// Named files in a parameterised directory, plus the version they are judged
|
|
80
144
|
// against — the changelog shapes. `dir` is stated by the fixture rather than
|
|
81
145
|
// assumed here, because the self-test sees the module's *raw* table and a
|
|
@@ -87,7 +151,19 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
87
151
|
// `changelog.d/*.md` does not match. The gate then reports "did not fire"
|
|
88
152
|
// about the harness rather than the fixture.
|
|
89
153
|
const written = fx.fragments.map((n: string) => write(`${dir}/${n}`, `# ${n}\n`));
|
|
90
|
-
|
|
154
|
+
if (fx.version_file === 'Directory.Build.props') {
|
|
155
|
+
written.push(write('Directory.Build.props', `<Project><PropertyGroup><Version>${fx.version}</Version></PropertyGroup></Project>\n`));
|
|
156
|
+
} else if (fx.version_file === 'pyproject.toml') {
|
|
157
|
+
written.push(write('pyproject.toml', `[project]\nversion = "${fx.version}"\n`));
|
|
158
|
+
} else {
|
|
159
|
+
written.push(write('package.json', JSON.stringify({ version: fx.version })));
|
|
160
|
+
}
|
|
161
|
+
// `consumed_through` is intentionally presence-sensitive: omitting it builds
|
|
162
|
+
// the missing-marker failure, while an empty string builds the blank-marker
|
|
163
|
+
// failure. Truthiness would collapse both into the same fixture.
|
|
164
|
+
if ('consumed_through' in fx) {
|
|
165
|
+
written.push(write(`${dir}/CONSUMED_THROUGH`, `${fx.consumed_through}\n`));
|
|
166
|
+
}
|
|
91
167
|
return written;
|
|
92
168
|
}
|
|
93
169
|
|
|
@@ -128,8 +204,9 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
128
204
|
}
|
|
129
205
|
|
|
130
206
|
/**
|
|
131
|
-
* Engines whose verdict
|
|
132
|
-
*
|
|
207
|
+
* Engines whose verdict can be reproduced completely by a fixture builder.
|
|
208
|
+
* Most depend only on content in an empty directory; `change-requires-file`
|
|
209
|
+
* gets the explicit Git repository built above.
|
|
133
210
|
*
|
|
134
211
|
* The rest need context the fixture does not carry, and running them anyway
|
|
135
212
|
* produces confident nonsense. `gates-links-resolve`'s `pass` fixture is
|
|
@@ -152,6 +229,7 @@ const CONTEXT_FREE: ReadonlySet<string> = new Set([
|
|
|
152
229
|
'register-schema',
|
|
153
230
|
'file-population',
|
|
154
231
|
'changelog-freshness',
|
|
232
|
+
'change-requires-file',
|
|
155
233
|
'computed-claim',
|
|
156
234
|
]);
|
|
157
235
|
|
|
@@ -168,7 +246,7 @@ const CONTEXT_FREE: ReadonlySet<string> = new Set([
|
|
|
168
246
|
*/
|
|
169
247
|
function deparam<T>(spec: T, dir: string): T {
|
|
170
248
|
const walk = (v: any): any =>
|
|
171
|
-
typeof v === 'string' ? v.replace(/\{\{
|
|
249
|
+
typeof v === 'string' ? v.replace(/\{\{changelog_dir\}\}/g, dir)
|
|
172
250
|
: Array.isArray(v) ? v.map(walk)
|
|
173
251
|
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
|
|
174
252
|
: v;
|
|
@@ -205,12 +283,11 @@ export function runSelfTests(
|
|
|
205
283
|
// Same bridge, for paths: a fixture that names a parameterised directory
|
|
206
284
|
// has to hand the spec the same literal it wrote the files into.
|
|
207
285
|
if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? 'changelog.d');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
spec = Array.isArray(spec) ? spec.map((s: any) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
|
|
286
|
+
if (Array.isArray(b.fixture?.changed)) {
|
|
287
|
+
const base = b.fixture.base_branch ?? 'main';
|
|
288
|
+
spec = Array.isArray(spec)
|
|
289
|
+
? spec.map((s: any) => ({ ...s, base_branch: base }))
|
|
290
|
+
: { ...spec, base_branch: base };
|
|
214
291
|
}
|
|
215
292
|
if (!files) {
|
|
216
293
|
out.push({ gate: gateId, expect, outcome: 'unrun', detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A conservative, locale-independent key for one already-separated storage
|
|
3
|
+
* segment that may move between a case-sensitive checkout, Windows, and
|
|
4
|
+
* default case-insensitive macOS APFS.
|
|
5
|
+
*
|
|
6
|
+
* NFKD exposes compatibility forms, while the lower/upper sequence expands
|
|
7
|
+
* multi-code-point case forms such as sharp-S. The final normalization catches
|
|
8
|
+
* decompositions introduced by case conversion itself.
|
|
9
|
+
*
|
|
10
|
+
* Do not pass a complete path or ref here. Compatibility decomposition can
|
|
11
|
+
* turn U+FF3C or U+FF0F into a separator; callers must split first so folding
|
|
12
|
+
* cannot manufacture path structure.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalCaselessSegmentKey(segment: string): string {
|
|
15
|
+
return segment.normalize('NFKD').toLowerCase().toUpperCase().normalize('NFKD');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function canonicalCaselessSegmentEqual(left: string, right: string): boolean {
|
|
19
|
+
return canonicalCaselessSegmentKey(left) === canonicalCaselessSegmentKey(right);
|
|
20
|
+
}
|