@rungs/cli 0.3.0 → 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 +2194 -488
- 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 +40 -32
- package/src/engines2.ts +424 -29
- package/src/engines3.ts +115 -23
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +95 -31
- 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/engines3.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { matchAny } from './glob.ts';
|
|
5
5
|
import type { Engine, Finding } from './engines.ts';
|
|
6
|
+
import { readVersionSource } from './version-source.ts';
|
|
7
|
+
import { semanticText } from './text.ts';
|
|
6
8
|
|
|
7
9
|
const read = (root: string, rel: string) => {
|
|
8
10
|
try {
|
|
9
|
-
return readFileSync(join(root, rel), 'utf8');
|
|
11
|
+
return semanticText(readFileSync(join(root, rel), 'utf8'));
|
|
10
12
|
} catch {
|
|
11
13
|
return '';
|
|
12
14
|
}
|
|
@@ -34,10 +36,15 @@ export function versionCmp(a: number[], b: number[]): number {
|
|
|
34
36
|
* `changelog.d/0.1.1.md` survived two releases and was still there at 0.2.0
|
|
35
37
|
* preparation (F-022). So the rule becomes mechanical.
|
|
36
38
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* The repository also states the last version whose fragments were consumed.
|
|
40
|
+
* That boundary must equal the package version in a steady tree, and a fragment
|
|
41
|
+
* at or below it is stale. Equality is what closes F-025: after a release, the
|
|
42
|
+
* forgotten fragment and package version are equal, so comparing only those two
|
|
43
|
+
* values cannot tell preparation from already-consumed work.
|
|
44
|
+
*
|
|
45
|
+
* Files whose names are not versions are ignored rather than reported: the
|
|
46
|
+
* module's own fixtures use `42.feature.md`, and a gate that refuses a naming
|
|
47
|
+
* convention it was not asked about is a gate people disable.
|
|
41
48
|
*/
|
|
42
49
|
export const changelogFreshness: Engine = (t, root, files) => {
|
|
43
50
|
const specs = Array.isArray(t) ? t : [t];
|
|
@@ -45,34 +52,119 @@ export const changelogFreshness: Engine = (t, root, files) => {
|
|
|
45
52
|
let examined = 0;
|
|
46
53
|
|
|
47
54
|
for (const spec of specs) {
|
|
48
|
-
const
|
|
55
|
+
const sources = Array.isArray(spec.versions)
|
|
56
|
+
? spec.versions
|
|
57
|
+
: [spec.version ?? { file: 'package.json', path: 'version' }];
|
|
49
58
|
let current: number[] | null = null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
let currentSource = '';
|
|
60
|
+
const versionProblems: Finding[] = [];
|
|
61
|
+
for (const source of sources) {
|
|
62
|
+
for (const rel of matchAny(files, source.file ?? 'package.json')) {
|
|
63
|
+
const result = readVersionSource(root, rel, source);
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
versionProblems.push({ file: rel, message: `release version source ${result.reason}` });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const parsed = versionParts(result.value);
|
|
69
|
+
if (!parsed) {
|
|
70
|
+
versionProblems.push({
|
|
71
|
+
file: rel,
|
|
72
|
+
message: `release version source must contain a three-part numeric version; found ${JSON.stringify(result.value)}`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
current = parsed;
|
|
77
|
+
if (current) {
|
|
78
|
+
currentSource = rel;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
58
81
|
}
|
|
59
82
|
if (current) break;
|
|
60
83
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!
|
|
84
|
+
const marker = spec.consumed_through ?? 'changelog.d/CONSUMED_THROUGH';
|
|
85
|
+
const markerExists = existsSync(join(root, marker));
|
|
86
|
+
if (!markerExists) {
|
|
87
|
+
findings.push({
|
|
88
|
+
file: marker,
|
|
89
|
+
message:
|
|
90
|
+
`release consumption marker '${marker}' is missing — create it with 'none' if no release has consumed fragments, or the exact last consumed version`,
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
examined++;
|
|
96
|
+
const markerRaw = read(root, marker);
|
|
97
|
+
const markerValue = markerRaw.endsWith('\r\n')
|
|
98
|
+
? markerRaw.slice(0, -2)
|
|
99
|
+
: markerRaw.endsWith('\n')
|
|
100
|
+
? markerRaw.slice(0, -1)
|
|
101
|
+
: markerRaw;
|
|
102
|
+
|
|
103
|
+
if (markerValue === 'UNINITIALIZED') {
|
|
104
|
+
findings.push({
|
|
105
|
+
file: marker,
|
|
106
|
+
message:
|
|
107
|
+
`release consumption marker '${marker}' is UNINITIALIZED — replace it with 'none' if no release has consumed fragments, or the exact last consumed version`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const firstRelease = markerValue === 'none';
|
|
113
|
+
// `versionParts` intentionally trims package metadata. The marker is a
|
|
114
|
+
// one-token schema, so validate its complete content before reusing that
|
|
115
|
+
// parser; whitespace, a BOM or a second newline must not become valid.
|
|
116
|
+
const consumed = firstRelease || !/^\d+\.\d+\.\d+$/.test(markerValue)
|
|
117
|
+
? null
|
|
118
|
+
: versionParts(markerValue);
|
|
119
|
+
if (!firstRelease && !consumed) {
|
|
120
|
+
findings.push({
|
|
121
|
+
file: marker,
|
|
122
|
+
message:
|
|
123
|
+
`release consumption marker '${marker}' must contain exactly 'none' or a three-part numeric version; found ${JSON.stringify(markerValue)}`,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
findings.push(...versionProblems);
|
|
129
|
+
|
|
130
|
+
// Without a package version there used to be no claim to make. A concrete
|
|
131
|
+
// consumption boundary changes that: its steady-state equality cannot be
|
|
132
|
+
// checked, so reporting green would make the new state assertion vacuous.
|
|
133
|
+
if (!current) {
|
|
134
|
+
if (consumed) {
|
|
135
|
+
findings.push({
|
|
136
|
+
file: sources[0]?.file ?? 'package.json',
|
|
137
|
+
message:
|
|
138
|
+
`cannot reconcile consumed-through ${markerValue} because none of the declared version sources contains a three-part numeric version`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (consumed && versionCmp(consumed, current) !== 0) {
|
|
145
|
+
const relation = versionCmp(consumed, current) < 0 ? 'below' : 'above';
|
|
146
|
+
findings.push({
|
|
147
|
+
file: marker,
|
|
148
|
+
message:
|
|
149
|
+
`release consumption marker names ${markerValue}, ${relation} version ${current.join('.')} in ${currentSource} — they must match in a steady tree; advance both during reversible release preparation`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
64
152
|
|
|
65
153
|
for (const rel of expand(files, spec.fragments, [])) {
|
|
66
154
|
const name = rel.split('/').pop()!.replace(/\.md$/, '');
|
|
67
155
|
const v = versionParts(name);
|
|
68
156
|
if (!v) continue;
|
|
69
157
|
examined++;
|
|
70
|
-
|
|
158
|
+
const belowPackage = versionCmp(v, current) < 0;
|
|
159
|
+
const alreadyConsumed = consumed ? versionCmp(v, consumed) <= 0 : false;
|
|
160
|
+
if (belowPackage || alreadyConsumed) {
|
|
71
161
|
findings.push({
|
|
72
162
|
file: rel,
|
|
73
163
|
message:
|
|
74
164
|
spec.message?.trim() ||
|
|
75
|
-
|
|
165
|
+
(alreadyConsumed
|
|
166
|
+
? `fragment names ${name}, at or below consumed-through ${markerValue} — it was already assembled and should have been deleted`
|
|
167
|
+
: `fragment names ${name}, below package version ${current.join('.')} — it belongs to an earlier release and should have been deleted`),
|
|
76
168
|
});
|
|
77
169
|
}
|
|
78
170
|
}
|
|
@@ -213,7 +305,7 @@ export const rulePropagation: Engine = (t, root, files) => {
|
|
|
213
305
|
export const gitState: Engine = (t, root) => {
|
|
214
306
|
let out: string;
|
|
215
307
|
try {
|
|
216
|
-
out =
|
|
308
|
+
out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
|
|
217
309
|
} catch {
|
|
218
310
|
// Not a git repo, or git unavailable. An unattributable result blocks:
|
|
219
311
|
// we do not land on an unknown.
|
|
@@ -248,7 +340,8 @@ export const mergeDriverCheck: Engine = (t, root) => {
|
|
|
248
340
|
for (const driver of required) {
|
|
249
341
|
let configured = '';
|
|
250
342
|
try {
|
|
251
|
-
|
|
343
|
+
// Driver names come from `.gitattributes`, so they reach this as data.
|
|
344
|
+
configured = execFileSync('git', ['config', '--get', `merge.${driver}.driver`], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
252
345
|
} catch {
|
|
253
346
|
/* absent config exits non-zero, which is the finding */
|
|
254
347
|
}
|
|
@@ -338,4 +431,3 @@ export const boardReconcile: Engine = (t, root, _files) => {
|
|
|
338
431
|
|
|
339
432
|
return { findings, examined };
|
|
340
433
|
};
|
|
341
|
-
|
package/src/explain.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ENGINES, type Finding } from './engines.ts';
|
|
2
|
-
import { loadTable
|
|
2
|
+
import { loadTable } from './check.ts';
|
|
3
3
|
import type { DetectResult, Manifest } from './types.ts';
|
|
4
|
+
import { selectEngineTable } from './engine-table.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `doctor` answers a *presence* question — which of our modules does this repo
|
|
@@ -138,12 +139,7 @@ export function explainWith(
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
try {
|
|
141
|
-
const
|
|
142
|
-
let section = table[key] ?? table;
|
|
143
|
-
if (Array.isArray(section) && section.some((s: any) => s?.id)) {
|
|
144
|
-
const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
|
|
145
|
-
if (mine.length) section = mine;
|
|
146
|
-
}
|
|
142
|
+
const section = selectEngineTable(table, g.engine!, g.id);
|
|
147
143
|
const r = engines[g.engine!](section, repoRoot, files);
|
|
148
144
|
if (r.findings.length) {
|
|
149
145
|
reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
|
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
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
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);
|
|
@@ -334,8 +393,13 @@ export function setupGit(repoRoot: string, dryRun = false) {
|
|
|
334
393
|
: 'git merge-file -L ours -L base -L theirs %A %O %B';
|
|
335
394
|
if (!dryRun) {
|
|
336
395
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
396
|
+
// argv, not a shell string. The `rungs-generated` driver command carries
|
|
397
|
+
// single quotes, a literal `\n` and `%A %O %B`, and it was being handed
|
|
398
|
+
// to a shell through `JSON.stringify` — quoting that happens to survive
|
|
399
|
+
// cmd.exe and does not survive bash the same way. The same class of bug
|
|
400
|
+
// as F-033, found in the same sweep.
|
|
401
|
+
execFileSync('git', ['config', `merge.${d}.name`, `rungs ${d.replace('rungs-', '')} driver`], { cwd: repoRoot, stdio: 'pipe' });
|
|
402
|
+
execFileSync('git', ['config', `merge.${d}.driver`, cmd], { cwd: repoRoot, stdio: 'pipe' });
|
|
339
403
|
} catch {
|
|
340
404
|
continue;
|
|
341
405
|
}
|
|
@@ -345,7 +409,7 @@ export function setupGit(repoRoot: string, dryRun = false) {
|
|
|
345
409
|
let rerere = false;
|
|
346
410
|
if (!dryRun) {
|
|
347
411
|
try {
|
|
348
|
-
|
|
412
|
+
execFileSync('git', ['config', 'rerere.enabled', 'true'], { cwd: repoRoot, stdio: 'pipe' });
|
|
349
413
|
rerere = true;
|
|
350
414
|
} catch {
|
|
351
415
|
/* not a git repo */
|
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
|
}
|