@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/render.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { walk } from './glob.ts';
|
|
4
|
+
import { preflightEmittedPaths, resolveEmittedPath, type EmittedPathCandidate } from './emitted-path.ts';
|
|
5
|
+
import { semanticText } from './text.ts';
|
|
4
6
|
|
|
5
7
|
export type Harness = 'claude' | 'copilot' | 'cursor' | 'agents-md';
|
|
6
8
|
|
|
@@ -20,12 +22,18 @@ export interface RenderEntry {
|
|
|
20
22
|
dropped?: string[];
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export interface ProspectiveRule {
|
|
26
|
+
moduleName: string;
|
|
27
|
+
target: string;
|
|
28
|
+
content: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
const DO_NOT_EDIT = (source: string) =>
|
|
24
32
|
`Generated by \`rungs render\` from ${source}. Do not edit — your changes are overwritten.`;
|
|
25
33
|
|
|
26
34
|
/** Parse `.ai/rules/*.md`: the neutral source ADR-0001 renders from. */
|
|
27
35
|
export function readRules(repoRoot: string): Rule[] {
|
|
28
|
-
const dir =
|
|
36
|
+
const dir = resolveEmittedPath(repoRoot, 'render', '.ai/rules').absolute;
|
|
29
37
|
const rules: Rule[] = [];
|
|
30
38
|
let files: string[];
|
|
31
39
|
try {
|
|
@@ -34,21 +42,26 @@ export function readRules(repoRoot: string): Rule[] {
|
|
|
34
42
|
return rules;
|
|
35
43
|
}
|
|
36
44
|
for (const rel of files) {
|
|
37
|
-
const raw = readFileSync(join(dir, rel), 'utf8');
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
const [, fm, body] = m;
|
|
41
|
-
rules.push({
|
|
42
|
-
file: rel,
|
|
43
|
-
description: scalar(fm, 'description'),
|
|
44
|
-
paths: list(fm, 'paths'),
|
|
45
|
-
enforcement: scalar(fm, 'enforcement'),
|
|
46
|
-
body: body.trim(),
|
|
47
|
-
});
|
|
45
|
+
const raw = semanticText(readFileSync(join(dir, rel), 'utf8'));
|
|
46
|
+
const rule = parseRule(rel, raw);
|
|
47
|
+
if (rule) rules.push(rule);
|
|
48
48
|
}
|
|
49
49
|
return rules;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function parseRule(file: string, raw: string): Rule | null {
|
|
53
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const [, fm, body] = m;
|
|
56
|
+
return {
|
|
57
|
+
file,
|
|
58
|
+
description: scalar(fm, 'description'),
|
|
59
|
+
paths: list(fm, 'paths'),
|
|
60
|
+
enforcement: scalar(fm, 'enforcement'),
|
|
61
|
+
body: body.trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
function scalar(fm: string, key: string): string | undefined {
|
|
53
66
|
const folded = fm.match(new RegExp(`^${key}:\\s*>-?\\s*\\n([\\s\\S]*?)(?=\\n\\S|$)`, 'm'));
|
|
54
67
|
if (folded) return folded[1].split('\n').map((l) => l.trim()).filter(Boolean).join(' ');
|
|
@@ -127,10 +140,40 @@ function commonDirPrefix(paths: string[]): string | null {
|
|
|
127
140
|
return dirs.every((d) => d === first) && first.includes('/') ? first : null;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
|
|
143
|
+
interface PreparedRender {
|
|
144
|
+
entries: RenderEntry[];
|
|
145
|
+
outputs: { entry: RenderEntry; absolute: string; content: string }[];
|
|
146
|
+
routingOnly: Rule[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function prepareRender(
|
|
150
|
+
repoRoot: string,
|
|
151
|
+
harnesses: Harness[],
|
|
152
|
+
prospective: ProspectiveRule[] = [],
|
|
153
|
+
preceding: EmittedPathCandidate[] = [],
|
|
154
|
+
): PreparedRender {
|
|
131
155
|
const rules = readRules(repoRoot);
|
|
156
|
+
const owners = new Map(rules.map((rule) => [rule.file, 'render']));
|
|
157
|
+
const occupied = new Set(rules.map((rule) => `.ai/rules/${rule.file}`));
|
|
158
|
+
|
|
159
|
+
// Model the installer's no-overwrite rule: an existing source remains the
|
|
160
|
+
// one rendered. New prospective rules are considered in module order, just
|
|
161
|
+
// as addModule will create the first and keep any later collision.
|
|
162
|
+
for (const pending of prospective) {
|
|
163
|
+
const source = resolveEmittedPath(repoRoot, pending.moduleName, pending.target);
|
|
164
|
+
if (occupied.has(source.target) || existsSync(source.absolute)) continue;
|
|
165
|
+
occupied.add(source.target);
|
|
166
|
+
if (!source.target.startsWith('.ai/rules/')) continue;
|
|
167
|
+
const file = source.target.slice('.ai/rules/'.length);
|
|
168
|
+
const rule = parseRule(file, pending.content);
|
|
169
|
+
if (!rule) continue;
|
|
170
|
+
rules.push(rule);
|
|
171
|
+
owners.set(file, pending.moduleName);
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
const entries: RenderEntry[] = [];
|
|
133
175
|
const routingOnly: Rule[] = [];
|
|
176
|
+
const planned: { entry: RenderEntry; target: string; content: string; owner: string }[] = [];
|
|
134
177
|
|
|
135
178
|
for (const rule of rules) {
|
|
136
179
|
for (const harness of harnesses) {
|
|
@@ -140,23 +183,62 @@ export function render(repoRoot: string, harnesses: Harness[]): RenderEntry[] {
|
|
|
140
183
|
if (harness === 'agents-md') routingOnly.push(rule);
|
|
141
184
|
continue;
|
|
142
185
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
entries.push({ rule: rule.file, harness, target: out.target, dropped: out.dropped });
|
|
186
|
+
const entry = { rule: rule.file, harness, target: out.target, dropped: out.dropped };
|
|
187
|
+
entries.push(entry);
|
|
188
|
+
planned.push({ entry, target: out.target, content: out.content, owner: owners.get(rule.file) ?? 'render' });
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
|
|
192
|
+
const candidates = [
|
|
193
|
+
...preceding,
|
|
194
|
+
...planned.map((out) => ({ moduleName: out.owner, target: out.target, writeExisting: true })),
|
|
195
|
+
...(harnesses.includes('agents-md')
|
|
196
|
+
? [{ moduleName: 'render', target: 'AGENTS.md', shared: true, writeExisting: true }]
|
|
197
|
+
: []),
|
|
198
|
+
{ moduleName: 'render', target: '.ai/render-report.md', writeExisting: true },
|
|
199
|
+
];
|
|
200
|
+
const resolved = preflightEmittedPaths(repoRoot, candidates);
|
|
201
|
+
return {
|
|
202
|
+
entries,
|
|
203
|
+
outputs: planned.map((out, index) => ({
|
|
204
|
+
entry: out.entry,
|
|
205
|
+
content: out.content,
|
|
206
|
+
absolute: resolved[preceding.length + index].absolute,
|
|
207
|
+
})),
|
|
208
|
+
routingOnly,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Validate all current and would-be post-install render outputs without writing. */
|
|
213
|
+
export function preflightRender(
|
|
214
|
+
repoRoot: string,
|
|
215
|
+
harnesses: Harness[],
|
|
216
|
+
prospective: ProspectiveRule[] = [],
|
|
217
|
+
preceding: EmittedPathCandidate[] = [],
|
|
218
|
+
): void {
|
|
219
|
+
prepareRender(repoRoot, harnesses, prospective, preceding);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function render(repoRoot: string, harnesses: Harness[]): RenderEntry[] {
|
|
223
|
+
const prepared = prepareRender(repoRoot, harnesses);
|
|
224
|
+
|
|
225
|
+
for (const output of prepared.outputs) {
|
|
226
|
+
mkdirSync(dirname(output.absolute), { recursive: true });
|
|
227
|
+
writeFileSync(output.absolute, output.content);
|
|
228
|
+
}
|
|
229
|
+
|
|
150
230
|
// The report said root AGENTS.md "gets a pointer" and nothing wrote one — a
|
|
151
231
|
// degradation notice that was itself a silent drop, in the function whose
|
|
152
232
|
// whole job is not to have those. Written now, as a managed block.
|
|
153
|
-
writeRoutingBlock(repoRoot, routingOnly, harnesses);
|
|
154
|
-
return entries;
|
|
233
|
+
writeRoutingBlock(repoRoot, prepared.routingOnly, harnesses);
|
|
234
|
+
return prepared.entries;
|
|
155
235
|
}
|
|
156
236
|
|
|
157
237
|
function writeRoutingBlock(repoRoot: string, rules: Rule[], harnesses: Harness[]) {
|
|
158
238
|
if (!harnesses.includes('agents-md')) return;
|
|
159
|
-
const target =
|
|
239
|
+
const target = preflightEmittedPaths(repoRoot, [
|
|
240
|
+
{ moduleName: 'render', target: 'AGENTS.md', shared: true, writeExisting: true },
|
|
241
|
+
])[0].absolute;
|
|
160
242
|
if (!existsSync(target)) return;
|
|
161
243
|
const begin = '<!-- rungs:begin rules-routing -->';
|
|
162
244
|
const end = '<!-- rungs:end rules-routing -->';
|
|
@@ -217,6 +299,9 @@ export function writeReport(repoRoot: string, entries: RenderEntry[], harnesses:
|
|
|
217
299
|
'',
|
|
218
300
|
);
|
|
219
301
|
const content = lines.join('\n');
|
|
220
|
-
|
|
302
|
+
const target = preflightEmittedPaths(repoRoot, [
|
|
303
|
+
{ moduleName: 'render', target: '.ai/render-report.md', writeExisting: true },
|
|
304
|
+
])[0].absolute;
|
|
305
|
+
writeFileSync(target, content);
|
|
221
306
|
return content;
|
|
222
307
|
}
|
package/src/selftest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { tmpdir } from 'node:os';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { ENGINES, type Finding } from './engines.ts';
|
|
@@ -68,6 +69,49 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
68
69
|
if (typeof input === 'string') return [write(targetPath(table), `${input}\n`)];
|
|
69
70
|
if (!fx || typeof fx !== 'object') return null;
|
|
70
71
|
|
|
72
|
+
// A branch delta plus the companion files it carries. Unlike content-only
|
|
73
|
+
// fixtures this needs a real repository: the engine deliberately observes
|
|
74
|
+
// committed, staged, unstaged and untracked Git state rather than trusting a
|
|
75
|
+
// fixture's list as the answer.
|
|
76
|
+
if (Array.isArray(fx.changed) && Array.isArray(fx.fragments)) {
|
|
77
|
+
const git = (...args: string[]) =>
|
|
78
|
+
execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
79
|
+
const changed = fx.changed.map(String);
|
|
80
|
+
git('init', '-q', '-b', 'main', '.');
|
|
81
|
+
git('config', 'user.email', 'selftest@rungs.local');
|
|
82
|
+
git('config', 'user.name', 'rungs-selftest');
|
|
83
|
+
const written = [write('.fixture-base', 'base\n')];
|
|
84
|
+
if (changed.length && typeof fx.inherited_exempt === 'string') {
|
|
85
|
+
written.push(write(
|
|
86
|
+
changed[0],
|
|
87
|
+
`// ${fx.inherited_exempt}\nexport const fixtureState = 'base';\n`,
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
git('add', '--all');
|
|
91
|
+
git('commit', '-q', '-m', 'base');
|
|
92
|
+
git('switch', '-q', '-c', 'fixture/change');
|
|
93
|
+
|
|
94
|
+
for (const [index, rel] of changed.entries()) {
|
|
95
|
+
const evidence = index === 0 && typeof fx.exempt === 'string'
|
|
96
|
+
? fx.exempt
|
|
97
|
+
: index === 0 && typeof fx.inherited_exempt === 'string'
|
|
98
|
+
? fx.inherited_exempt
|
|
99
|
+
: undefined;
|
|
100
|
+
const body = evidence
|
|
101
|
+
? `// ${evidence}\nexport const fixtureState = 'branch';\n`
|
|
102
|
+
: 'fixture change\n';
|
|
103
|
+
written.push(write(rel, body));
|
|
104
|
+
}
|
|
105
|
+
const changelogDir = fx.dir ?? 'changelog.d';
|
|
106
|
+
for (const rel of fx.fragments) {
|
|
107
|
+
const concrete = String(rel).replace(/\{\{changelog_dir\}\}/g, changelogDir);
|
|
108
|
+
written.push(write(concrete, '# fixture fragment\n'));
|
|
109
|
+
}
|
|
110
|
+
git('add', '--all');
|
|
111
|
+
git('commit', '-q', '-m', 'fixture change');
|
|
112
|
+
return [...new Set(written)];
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
// A set of manifests and the version each states — the computed-claim shapes.
|
|
72
116
|
// `{ "package.json": "1.2.0", "site/package.json": "1.1.0" }`.
|
|
73
117
|
if (fx.packages && typeof fx.packages === 'object') {
|
|
@@ -76,6 +120,26 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
76
120
|
);
|
|
77
121
|
}
|
|
78
122
|
|
|
123
|
+
// Format-aware release-version sources. Values are rendered into the real
|
|
124
|
+
// source shape so one fixture can prove JSON, TOML and Directory.Build.props
|
|
125
|
+
// all participate in the same comparison.
|
|
126
|
+
if (fx.versions && typeof fx.versions === 'object') {
|
|
127
|
+
return Object.entries(fx.versions).map(([rel, version]) => {
|
|
128
|
+
const value = String(version).replace(/"/g, '\\"');
|
|
129
|
+
if (rel.endsWith('.toml')) return write(rel, `[project]\nversion = "${value}"\n`);
|
|
130
|
+
if (rel.endsWith('.props')) {
|
|
131
|
+
return write(rel, `<Project><PropertyGroup><Version>${String(version)}</Version></PropertyGroup></Project>\n`);
|
|
132
|
+
}
|
|
133
|
+
return write(rel, JSON.stringify({ name: rel.replace(/\W/g, '-'), version }));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Raw version-source fixtures preserve malformed documents and missing or
|
|
138
|
+
// non-scalar values exactly; normalising them would erase the failure under test.
|
|
139
|
+
if (fx.version_files && typeof fx.version_files === 'object') {
|
|
140
|
+
return Object.entries(fx.version_files).map(([rel, content]) => write(rel, String(content)));
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
// Named files in a parameterised directory, plus the version they are judged
|
|
80
144
|
// against — the changelog shapes. `dir` is stated by the fixture rather than
|
|
81
145
|
// assumed here, because the self-test sees the module's *raw* table and a
|
|
@@ -87,7 +151,19 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
87
151
|
// `changelog.d/*.md` does not match. The gate then reports "did not fire"
|
|
88
152
|
// about the harness rather than the fixture.
|
|
89
153
|
const written = fx.fragments.map((n: string) => write(`${dir}/${n}`, `# ${n}\n`));
|
|
90
|
-
|
|
154
|
+
if (fx.version_file === 'Directory.Build.props') {
|
|
155
|
+
written.push(write('Directory.Build.props', `<Project><PropertyGroup><Version>${fx.version}</Version></PropertyGroup></Project>\n`));
|
|
156
|
+
} else if (fx.version_file === 'pyproject.toml') {
|
|
157
|
+
written.push(write('pyproject.toml', `[project]\nversion = "${fx.version}"\n`));
|
|
158
|
+
} else {
|
|
159
|
+
written.push(write('package.json', JSON.stringify({ version: fx.version })));
|
|
160
|
+
}
|
|
161
|
+
// `consumed_through` is intentionally presence-sensitive: omitting it builds
|
|
162
|
+
// the missing-marker failure, while an empty string builds the blank-marker
|
|
163
|
+
// failure. Truthiness would collapse both into the same fixture.
|
|
164
|
+
if ('consumed_through' in fx) {
|
|
165
|
+
written.push(write(`${dir}/CONSUMED_THROUGH`, `${fx.consumed_through}\n`));
|
|
166
|
+
}
|
|
91
167
|
return written;
|
|
92
168
|
}
|
|
93
169
|
|
|
@@ -128,8 +204,9 @@ function build(root: string, table: any, fx: any, input?: string): string[] | nu
|
|
|
128
204
|
}
|
|
129
205
|
|
|
130
206
|
/**
|
|
131
|
-
* Engines whose verdict
|
|
132
|
-
*
|
|
207
|
+
* Engines whose verdict can be reproduced completely by a fixture builder.
|
|
208
|
+
* Most depend only on content in an empty directory; `change-requires-file`
|
|
209
|
+
* gets the explicit Git repository built above.
|
|
133
210
|
*
|
|
134
211
|
* The rest need context the fixture does not carry, and running them anyway
|
|
135
212
|
* produces confident nonsense. `gates-links-resolve`'s `pass` fixture is
|
|
@@ -152,6 +229,7 @@ const CONTEXT_FREE: ReadonlySet<string> = new Set([
|
|
|
152
229
|
'register-schema',
|
|
153
230
|
'file-population',
|
|
154
231
|
'changelog-freshness',
|
|
232
|
+
'change-requires-file',
|
|
155
233
|
'computed-claim',
|
|
156
234
|
]);
|
|
157
235
|
|
|
@@ -168,7 +246,7 @@ const CONTEXT_FREE: ReadonlySet<string> = new Set([
|
|
|
168
246
|
*/
|
|
169
247
|
function deparam<T>(spec: T, dir: string): T {
|
|
170
248
|
const walk = (v: any): any =>
|
|
171
|
-
typeof v === 'string' ? v.replace(/\{\{
|
|
249
|
+
typeof v === 'string' ? v.replace(/\{\{changelog_dir\}\}/g, dir)
|
|
172
250
|
: Array.isArray(v) ? v.map(walk)
|
|
173
251
|
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
|
|
174
252
|
: v;
|
|
@@ -205,12 +283,11 @@ export function runSelfTests(
|
|
|
205
283
|
// Same bridge, for paths: a fixture that names a parameterised directory
|
|
206
284
|
// has to hand the spec the same literal it wrote the files into.
|
|
207
285
|
if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? 'changelog.d');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
spec = Array.isArray(spec) ? spec.map((s: any) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
|
|
286
|
+
if (Array.isArray(b.fixture?.changed)) {
|
|
287
|
+
const base = b.fixture.base_branch ?? 'main';
|
|
288
|
+
spec = Array.isArray(spec)
|
|
289
|
+
? spec.map((s: any) => ({ ...s, base_branch: base }))
|
|
290
|
+
: { ...spec, base_branch: base };
|
|
214
291
|
}
|
|
215
292
|
if (!files) {
|
|
216
293
|
out.push({ gate: gateId, expect, outcome: 'unrun', detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A conservative, locale-independent key for one already-separated storage
|
|
3
|
+
* segment that may move between a case-sensitive checkout, Windows, and
|
|
4
|
+
* default case-insensitive macOS APFS.
|
|
5
|
+
*
|
|
6
|
+
* NFKD exposes compatibility forms, while the lower/upper sequence expands
|
|
7
|
+
* multi-code-point case forms such as sharp-S. The final normalization catches
|
|
8
|
+
* decompositions introduced by case conversion itself.
|
|
9
|
+
*
|
|
10
|
+
* Do not pass a complete path or ref here. Compatibility decomposition can
|
|
11
|
+
* turn U+FF3C or U+FF0F into a separator; callers must split first so folding
|
|
12
|
+
* cannot manufacture path structure.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalCaselessSegmentKey(segment: string): string {
|
|
15
|
+
return segment.normalize('NFKD').toLowerCase().toUpperCase().normalize('NFKD');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function canonicalCaselessSegmentEqual(left: string, right: string): boolean {
|
|
19
|
+
return canonicalCaselessSegmentKey(left) === canonicalCaselessSegmentKey(right);
|
|
20
|
+
}
|
package/src/substitute.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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 = {
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|