@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.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2194 -488
  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 +40 -32
  62. package/src/engines2.ts +424 -29
  63. package/src/engines3.ts +115 -23
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +95 -31
  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/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
+ }
package/src/substitute.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { basename, resolve } from 'node:path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import type { Manifest } from './types.ts';
3
5
 
4
6
  export type Params = Record<string, Record<string, unknown>>;
@@ -42,6 +44,20 @@ function repoFacts(repoRoot?: string): Record<string, unknown> {
42
44
  return repoRoot ? { dirname: basename(resolve(repoRoot)) } : {};
43
45
  }
44
46
 
47
+ /**
48
+ * Facts about the Rungs artifact doing the rendering. Source execution and the
49
+ * published bundle live in `src/` and `dist/` respectively, so the package
50
+ * manifest is one directory above `import.meta.url` in both cases.
51
+ *
52
+ * This is deliberately not a module parameter. Parameters are copied into an
53
+ * install record and retained on upgrade; a CLI version must instead advance
54
+ * when the consumer explicitly invokes a newer artifact.
55
+ */
56
+ function rungsFacts(): Record<string, unknown> {
57
+ const packageJson = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
58
+ return { version: JSON.parse(readFileSync(packageJson, 'utf8')).version };
59
+ }
60
+
45
61
  /**
46
62
  * Defaults from every manifest, with explicit overrides applied on top.
47
63
  *
@@ -51,7 +67,7 @@ function repoFacts(repoRoot?: string): Record<string, unknown> {
51
67
  * the reason a missing root shows up as a wrong-looking file instead of a silently blank heading.
52
68
  */
53
69
  export function resolveParams(mods: Manifest[], overrides: Params = {}, repoRoot?: string): Params {
54
- const out: Params = { repo: repoFacts(repoRoot) };
70
+ const out: Params = {};
55
71
  for (const m of mods) {
56
72
  out[m.name] = {};
57
73
  for (const [k, spec] of Object.entries(m.params)) out[m.name][k] = spec.default;
@@ -68,6 +84,11 @@ export function resolveParams(mods: Manifest[], overrides: Params = {}, repoRoot
68
84
  out[mod] = { ...(out[mod] ?? {}), ...vals };
69
85
  }
70
86
 
87
+ // Facts win over manifests and overrides: both namespaces describe the
88
+ // execution context, not consumer configuration.
89
+ out.repo = repoFacts(repoRoot);
90
+ out.rungs = rungsFacts();
91
+
71
92
  // A default may reference another module's parameter, e.g. findings' register
72
93
  // living at `docs/{{backlog.root}}/FINDINGS.md`. One level only — a chain
73
94
  // would be a template language arriving through the back door.
@@ -95,14 +116,35 @@ export function markers(targetPath: string, module: string, version: string) {
95
116
  * mechanical and divergence a decision rather than an error.
96
117
  */
97
118
  export function mergeBlock(existing: string, fragment: string, module: string): string {
98
- const beginRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:begin ${module}(?:@[\\w.\\-]+)?\\s*(?:-->)?[ \\t]*$`, 'm');
99
- const endRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:end ${module}\\s*(?:-->)?[ \\t]*$`, 'm');
119
+ // Marker whitespace belongs to the marker line. `\\s*` also consumes newlines,
120
+ // which made the end match swallow inter-block separators and the file's final
121
+ // newline whenever an unchanged gate block was registered again (F-040). The
122
+ // match deliberately stops before CR/LF so those surrounding bytes stay outside
123
+ // the managed block.
124
+ const beginRe = new RegExp(
125
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:begin ${module}(?:@[\\w.\\-]+)?[ \\t]*(?:-->)?[ \\t]*$`,
126
+ 'm',
127
+ );
128
+ const endRe = new RegExp(
129
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:end ${module}[ \\t]*(?:-->)?[ \\t]*$`,
130
+ 'm',
131
+ );
100
132
  const b = existing.match(beginRe);
101
133
  const e = existing.match(endRe);
102
134
  if (b && e && b.index !== undefined && e.index !== undefined && e.index > b.index) {
103
135
  const before = existing.slice(0, b.index);
104
136
  const after = existing.slice(e.index + e[0].length);
105
- return `${before}${fragment.trim()}${after}`;
137
+ const current = existing.slice(b.index, e.index + e[0].length);
138
+ const normalise = (value: string) => value.replace(/\r\n|\r|\n/g, '\n');
139
+ const replacement = fragment.trim();
140
+
141
+ // Registration builds fragments with LF on every platform. If the managed
142
+ // content is otherwise identical, preserve the original bytes—including a
143
+ // consumer checkout's CRLF convention—rather than manufacturing a diff.
144
+ if (normalise(current) === normalise(replacement)) return existing;
145
+
146
+ const newline = current.match(/\r\n|\r|\n/)?.[0] ?? existing.match(/\r\n|\r|\n/)?.[0] ?? '\n';
147
+ return `${before}${normalise(replacement).replace(/\n/g, newline)}${after}`;
106
148
  }
107
149
  const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
108
150
  return `${existing}${sep}${fragment.trim()}\n`;
package/src/text.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Normalize decoded repository text for semantic parsing only.
3
+ *
4
+ * Git may materialize tracked text as CRLF in a consumer even when the package
5
+ * source is LF. Parsers should not change their verdict with that checkout
6
+ * policy. Callers that compare ownership hashes or promise byte preservation
7
+ * must continue reading the original bytes instead.
8
+ */
9
+ export function semanticText(text: string): string {
10
+ return text.replace(/\r\n?/g, '\n');
11
+ }
package/src/types.ts CHANGED
@@ -89,9 +89,22 @@ export interface DetectSpec {
89
89
  }
90
90
 
91
91
  export interface Provenance {
92
- sources: string[];
93
- patterns: string[];
94
- incident: string;
92
+ /**
93
+ * Whether the module was **extracted** from a repo that already paid for it,
94
+ * or **designed** by somebody who thought it was a good idea.
95
+ *
96
+ * Absent means `extracted`: all fifteen bundled modules are, and normalising
97
+ * fifteen manifests to declare what they already said would be a migration
98
+ * rather than a distinction. `loadManifest` fills it in, so a reader may rely
99
+ * on it.
100
+ */
101
+ kind: 'extracted' | 'designed';
102
+ /** Required for `extracted`; forbidden for `designed` — see `loadManifest`. */
103
+ sources?: string[];
104
+ patterns?: string[];
105
+ incident?: string;
106
+ /** Required for `designed`: why it exists, in the first person. */
107
+ rationale?: string;
95
108
  }
96
109
 
97
110
  export interface Manifest {
@@ -0,0 +1,144 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { SaxesParser } from 'saxes';
4
+ import { parse as parseToml } from 'smol-toml';
5
+
6
+ /** A version location declared by a gate table. Pattern matching stays with the caller. */
7
+ export interface VersionSource {
8
+ file?: string;
9
+ path?: string;
10
+ xpath?: string;
11
+ }
12
+
13
+ /**
14
+ * A matched source either contributes one comparable value or explains why it cannot.
15
+ * There is deliberately no "not found" result: callers own globs and only call this
16
+ * reader after a concrete file matched.
17
+ */
18
+ export type VersionSourceResult =
19
+ | { ok: true; value: string }
20
+ | { ok: false; reason: string };
21
+
22
+ const invalidScalar = (where: string): VersionSourceResult => ({
23
+ ok: false,
24
+ reason: `${where} is not a non-empty string or finite number`,
25
+ });
26
+
27
+ function scalar(value: unknown, where: string): VersionSourceResult {
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ return trimmed ? { ok: true, value: trimmed } : invalidScalar(where);
31
+ }
32
+ if (typeof value === 'number' && Number.isFinite(value)) {
33
+ return { ok: true, value: String(value) };
34
+ }
35
+ return invalidScalar(where);
36
+ }
37
+
38
+ function dottedValue(parsed: unknown, path: string): { found: true; value: unknown } | { found: false } {
39
+ if (!path.trim()) return { found: false };
40
+ let value: unknown = parsed;
41
+ for (const key of path.split('.')) {
42
+ if (!value || typeof value !== 'object' || !Object.hasOwn(value, key)) return { found: false };
43
+ value = (value as Record<string, unknown>)[key];
44
+ }
45
+ return { found: true, value };
46
+ }
47
+
48
+ function errorMessage(error: unknown): string {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+
52
+ function xmlElement(text: string, rel: string, xpath: string): VersionSourceResult {
53
+ const match = /^\/\/([A-Za-z_][A-Za-z0-9_.:-]*)$/.exec(xpath);
54
+ if (!match) return { ok: false, reason: `unsupported XML xpath '${xpath}'; expected //Element` };
55
+
56
+ const element = match[1];
57
+ const values: { text: string; nested: boolean }[] = [];
58
+ const active: number[] = [];
59
+
60
+ try {
61
+ // Saxes validates a complete XML document and does not expand declarations
62
+ // from a DTD. Refuse the DTD outright so a version is always literal document
63
+ // evidence rather than an entity whose definition lives elsewhere.
64
+ const parser = new SaxesParser({ fragment: false, xmlns: false, fileName: rel });
65
+ parser.on('doctype', () => {
66
+ throw new Error('DOCTYPE declarations are not supported in version sources');
67
+ });
68
+ parser.on('opentag', (tag) => {
69
+ for (const index of active) values[index].nested = true;
70
+ if (tag.name === element) {
71
+ values.push({ text: '', nested: false });
72
+ active.push(values.length - 1);
73
+ }
74
+ });
75
+ const append = (value: string) => {
76
+ for (const index of active) values[index].text += value;
77
+ };
78
+ parser.on('text', append);
79
+ parser.on('cdata', append);
80
+ parser.on('closetag', (tag) => {
81
+ if (tag.name === element) active.pop();
82
+ });
83
+ parser.write(text).close();
84
+ } catch (error) {
85
+ return { ok: false, reason: `contains invalid XML: ${errorMessage(error)}` };
86
+ }
87
+
88
+ if (!values.length) return { ok: false, reason: `does not contain configured element '${xpath}'` };
89
+ if (values.length > 1) {
90
+ return { ok: false, reason: `configured element '${xpath}' matched ${values.length} values; expected one` };
91
+ }
92
+ if (values[0].nested) {
93
+ return { ok: false, reason: `configured element '${xpath}' contains nested XML; expected scalar text` };
94
+ }
95
+ return scalar(values[0].text, `configured element '${xpath}'`);
96
+ }
97
+
98
+ /**
99
+ * Read one already-matched version source.
100
+ *
101
+ * `path` means dotted JSON/TOML lookup, selected from the concrete filename.
102
+ * `xpath` intentionally supports only the release module's narrow `//Element`
103
+ * shape; pretending to implement general XPath would make a green result false.
104
+ */
105
+ export function readVersionSource(root: string, rel: string, source: VersionSource): VersionSourceResult {
106
+ let text: string;
107
+ try {
108
+ text = readFileSync(join(root, rel), 'utf8');
109
+ } catch (error) {
110
+ return { ok: false, reason: `could not read version source: ${errorMessage(error)}` };
111
+ }
112
+
113
+ if (source.path && source.xpath) {
114
+ return { ok: false, reason: 'declares both `path` and `xpath`; choose one version lookup' };
115
+ }
116
+
117
+ if (source.path) {
118
+ const extension = extname(rel).toLowerCase();
119
+ let parsed: unknown;
120
+ try {
121
+ if (extension === '.json') parsed = JSON.parse(text);
122
+ else if (extension === '.toml') parsed = parseToml(text);
123
+ else {
124
+ return {
125
+ ok: false,
126
+ reason: `cannot read dotted path '${source.path}' from '${extension || '(no extension)'}'; use JSON or TOML`,
127
+ };
128
+ }
129
+ } catch (error) {
130
+ const format = extension === '.toml' ? 'TOML' : 'JSON';
131
+ return { ok: false, reason: `contains invalid ${format}: ${errorMessage(error)}` };
132
+ }
133
+
134
+ const found = dottedValue(parsed, source.path);
135
+ if (!found.found) return { ok: false, reason: `does not contain configured path '${source.path}'` };
136
+ return scalar(found.value, `configured path '${source.path}'`);
137
+ }
138
+
139
+ if (source.xpath) {
140
+ return xmlElement(text, rel, source.xpath);
141
+ }
142
+
143
+ return { ok: false, reason: 'declares neither `path` nor `xpath` for its version value' };
144
+ }