@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.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2184 -478
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +18 -29
  62. package/src/engines2.ts +403 -20
  63. package/src/engines3.ts +111 -20
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +86 -27
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. 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 p = join(repoRoot, '.ai', 'rungs.toml');
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 mods) {
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 full = join(repoRoot, rel);
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 item of plan) {
96
- const mod = mods.find((m) => m.name === item.module)!;
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 full = join(repoRoot, f.rel);
101
- const content = emitted.get(f.rel)!;
102
- mkdirSync(dirname(full), { recursive: true });
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.rel, contentHash(content));
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 = plan.map((p) => mods.find((m) => m.name === p.module)!).filter(Boolean);
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 = join(repoRoot, '.ai', 'rungs.toml');
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 lines = readFileSync(path, 'utf8').split('\n');
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
- out.push(`"${entry[1]}" = "${replacement}"`);
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
- writeFileSync(path, out.join('\n'));
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 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', 'self-declared-closure': 'self_declared_closure', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
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 `doctor` cannot ask its
32
- // questions without the incident.
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?.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
35
- if (!p?.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
36
- if (!p?.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
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 = join(repoRoot, '.ai', 'rules');
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 m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
39
- if (!m) continue;
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
- export function render(repoRoot: string, harnesses: Harness[]): RenderEntry[] {
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 full = join(repoRoot, out.target);
144
- mkdirSync(dirname(full), { recursive: true });
145
- writeFileSync(full, out.content);
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 = join(repoRoot, 'AGENTS.md');
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
- writeFileSync(join(repoRoot, '.ai', 'render-report.md'), content);
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
- written.push(write('package.json', JSON.stringify({ version: fx.version })));
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 depends **only on the content of the file the fixture
132
- * describes**, so a fixture can be executed faithfully in an empty directory.
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(/\{\{[^}]+\}\}/g, dir)
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
- // And for `exclude`, which is the thing under test in half these fixtures:
209
- // the table ships it empty by default, so a fixture proving exclusion works
210
- // has to set it, exactly as a repo would.
211
- if (Array.isArray(b.fixture?.exclude)) {
212
- const ex = b.fixture.exclude;
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
+ }