baldart 4.7.0 → 4.9.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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for overlay-capture (the seamless-update auto-capture + verify, v4.8.0).
4
+ *
5
+ * Run: node src/utils/__tests__/overlay-capture.test.js
6
+ * Exits 0 on success, non-zero on failure (CI-friendly via node:test).
7
+ *
8
+ * Contract under test: the user's "auto-capture + verify" choice — an uncovered
9
+ * framework edit is captured into an overlay ONLY when re-merging that overlay
10
+ * onto the fresh base reproduces the edit; otherwise it is a BLOCKER (the
11
+ * seamless flow stops once rather than risk a lossy capture).
12
+ */
13
+ const test = require('node:test');
14
+ const assert = require('node:assert');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ const {
20
+ buildOverlayBody, verifyCapture, kindNameFromPath, toRepoPath, captureAndVerify,
21
+ } = require('../overlay-capture');
22
+ const { buildFrontmatter } = require('../../commands/overlay');
23
+
24
+ const BASE = `---
25
+ name: ui-expert
26
+ description: UI agent
27
+ ---
28
+
29
+ Shared preamble.
30
+
31
+ ## Role
32
+ You are a UI expert.
33
+
34
+ ## Rules
35
+ Follow the design system.
36
+ `;
37
+
38
+ function overlayFor(body) {
39
+ return buildFrontmatter({ kind: 'agent', name: 'ui-expert', frameworkVersion: '4.8.0', baseSha: 'deadbeef', mode: 'extend' }) + body;
40
+ }
41
+ function roundTrips(edited) {
42
+ const built = buildOverlayBody(BASE, edited);
43
+ if (built.blocker) return { ok: false, blocker: built.blocker };
44
+ const ok = verifyCapture({
45
+ kind: 'agent', name: 'ui-expert', baseContent: BASE,
46
+ overlayContent: overlayFor(built.body), editedContent: edited, frameworkVersion: '4.8.0',
47
+ });
48
+ return { ok };
49
+ }
50
+
51
+ test('path helpers', () => {
52
+ assert.strictEqual(toRepoPath('.framework/framework/.claude/agents/x.md'), 'framework/.claude/agents/x.md');
53
+ assert.deepStrictEqual(kindNameFromPath('.framework/framework/.claude/agents/coder.md'), { kind: 'agent', name: 'coder' });
54
+ assert.deepStrictEqual(kindNameFromPath('.framework/framework/.claude/commands/check.md'), { kind: 'command', name: 'check' });
55
+ assert.deepStrictEqual(kindNameFromPath('.framework/framework/.claude/skills/bug/SKILL.md'), { kind: 'skill', name: 'bug' });
56
+ assert.strictEqual(kindNameFromPath('.framework/framework/src/index.js'), null);
57
+ });
58
+
59
+ test('OVERRIDE of an existing section → captured + verified', () => {
60
+ const edited = BASE.replace('Follow the design system.', 'Follow the MAYO design system.\nNever hardcode colors.');
61
+ assert.deepStrictEqual(roundTrips(edited), { ok: true });
62
+ });
63
+
64
+ test('new trailing section → captured + verified', () => {
65
+ const edited = BASE + '\n## Project notes\nMayo-specific guidance.\n';
66
+ assert.deepStrictEqual(roundTrips(edited), { ok: true });
67
+ });
68
+
69
+ test('section deletion → blocked (no marker can express it)', () => {
70
+ const edited = BASE.replace('\n## Rules\nFollow the design system.\n', '\n');
71
+ const built = buildOverlayBody(BASE, edited);
72
+ assert.match(built.blocker, /section-deleted/);
73
+ });
74
+
75
+ test('frontmatter change → blocked (merge keeps base frontmatter)', () => {
76
+ const edited = BASE.replace('description: UI agent', 'description: changed');
77
+ assert.strictEqual(buildOverlayBody(BASE, edited).blocker, 'frontmatter-changed');
78
+ });
79
+
80
+ test('preamble change → blocked (merge keeps base preamble)', () => {
81
+ const edited = BASE.replace('Shared preamble.', 'Changed preamble.');
82
+ assert.strictEqual(buildOverlayBody(BASE, edited).blocker, 'preamble-changed');
83
+ });
84
+
85
+ test('mid-file insertion → verify fails (order cannot be reproduced)', () => {
86
+ // Insert a new section BETWEEN Role and Rules — merge appends new sections at
87
+ // the end, so the ordered round-trip must fail (correctly surfaced as a blocker).
88
+ const edited = BASE.replace('## Rules', '## Inserted\nMid content.\n\n## Rules');
89
+ const r = roundTrips(edited);
90
+ assert.strictEqual(r.ok, false);
91
+ });
92
+
93
+ test('captureAndVerify writes a valid overlay for a verifiable edit', async () => {
94
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
95
+ try {
96
+ const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
97
+ const fwAbs = path.join(tmp, fwRel);
98
+ fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
99
+ const edited = BASE.replace('Follow the design system.', 'Follow the MAYO design system.');
100
+ fs.writeFileSync(fwAbs, edited);
101
+
102
+ const git = { git: { raw: async (args) => {
103
+ if (args[0] === 'show' && args[1] === `FETCH_HEAD:${toRepoPath(fwRel)}`) return BASE;
104
+ throw new Error(`unexpected: ${args.join(' ')}`);
105
+ } } };
106
+
107
+ const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.8.0' });
108
+ assert.deepStrictEqual(res.blockers, []);
109
+ assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
110
+ assert.ok(fs.existsSync(path.join(tmp, '.baldart/overlays/agents/ui-expert.md')));
111
+ } finally {
112
+ fs.rmSync(tmp, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ test('captureAndVerify: skill with NO overlay → blocker (runtime-concat, unverifiable)', async () => {
117
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
118
+ try {
119
+ const git = { git: { raw: async () => { throw new Error('should not be called'); } } };
120
+ const res = await captureAndVerify({
121
+ cwd: tmp, git,
122
+ files: ['.framework/framework/.claude/skills/ui-design/SKILL.md'],
123
+ frameworkVersion: '4.8.0',
124
+ });
125
+ assert.deepStrictEqual(res.captured, []);
126
+ assert.strictEqual(res.blockers.length, 1);
127
+ assert.match(res.blockers[0].reason, /runtime-concat/);
128
+ } finally {
129
+ fs.rmSync(tmp, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ test('captureAndVerify: skill WITH an existing overlay → trusted (captured, not blocked)', async () => {
134
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
135
+ try {
136
+ const overlay = path.join(tmp, '.baldart/overlays/ui-design.md');
137
+ fs.mkdirSync(path.dirname(overlay), { recursive: true });
138
+ fs.writeFileSync(overlay, '---\nbase_skill: ui-design\n---\n## [APPEND] Foo\nbar\n');
139
+ const git = { git: { raw: async () => { throw new Error('should not be called for skills'); } } };
140
+ const res = await captureAndVerify({
141
+ cwd: tmp, git,
142
+ files: ['.framework/framework/.claude/skills/ui-design/SKILL.md'],
143
+ frameworkVersion: '4.8.0',
144
+ });
145
+ assert.deepStrictEqual(res.blockers, []);
146
+ assert.deepStrictEqual(res.captured, ['.baldart/overlays/ui-design.md']);
147
+ } finally {
148
+ fs.rmSync(tmp, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('captureAndVerify: existing agent overlay that REPRODUCES the edit → captured (no rewrite)', async () => {
153
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
154
+ try {
155
+ const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
156
+ const fwAbs = path.join(tmp, fwRel);
157
+ fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
158
+ const edited = BASE.replace('Follow the design system.', 'Follow the MAYO design system.');
159
+ fs.writeFileSync(fwAbs, edited);
160
+ // a hand-authored overlay whose merge reproduces `edited`
161
+ const built = buildOverlayBody(BASE, edited);
162
+ const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
163
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
164
+ const original = overlayFor(built.body);
165
+ fs.writeFileSync(overlayAbs, original);
166
+
167
+ const git = { git: { raw: async (a) => (a[0] === 'show' ? BASE : '') } };
168
+ const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.8.0' });
169
+ assert.deepStrictEqual(res.blockers, []);
170
+ assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
171
+ // existing overlay must NOT be rewritten when it already verifies
172
+ assert.strictEqual(fs.readFileSync(overlayAbs, 'utf8'), original);
173
+ } finally {
174
+ fs.rmSync(tmp, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test('captureAndVerify: STALE existing agent overlay (does not reproduce edit) → blocker', async () => {
179
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
180
+ try {
181
+ const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
182
+ const fwAbs = path.join(tmp, fwRel);
183
+ fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
184
+ // .framework edit is NEWER than what the overlay captures
185
+ fs.writeFileSync(fwAbs, BASE.replace('Follow the design system.', 'Follow the NEW mayo rules v2.'));
186
+ const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
187
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
188
+ // overlay reproduces an OLD edit, not the current one
189
+ fs.writeFileSync(overlayAbs, overlayFor('\n## [OVERRIDE] Rules\nFollow the OLD mayo rules v1.\n'));
190
+
191
+ const git = { git: { raw: async (a) => (a[0] === 'show' ? BASE : '') } };
192
+ const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.8.0' });
193
+ assert.deepStrictEqual(res.captured, []);
194
+ assert.strictEqual(res.blockers.length, 1);
195
+ assert.match(res.blockers[0].reason, /stale/);
196
+ } finally {
197
+ fs.rmSync(tmp, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ test('captureAndVerify: unmappable overlay-able path (nested agent) → blocker, never silently dropped', async () => {
202
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
203
+ try {
204
+ const git = { git: { raw: async () => { throw new Error('nope'); } } };
205
+ const res = await captureAndVerify({
206
+ cwd: tmp, git,
207
+ files: ['.framework/framework/.claude/agents/sub/nested.md'],
208
+ frameworkVersion: '4.8.0',
209
+ });
210
+ assert.deepStrictEqual(res.captured, []);
211
+ assert.strictEqual(res.blockers.length, 1);
212
+ } finally {
213
+ fs.rmSync(tmp, { recursive: true, force: true });
214
+ }
215
+ });
package/src/utils/git.js CHANGED
@@ -173,21 +173,6 @@ class GitUtils {
173
173
  await this.git.fetch(repoUrl, branch);
174
174
  }
175
175
 
176
- async diffWithRemote() {
177
- try {
178
- const diff = await this.git.raw([
179
- 'diff',
180
- 'HEAD',
181
- 'FETCH_HEAD',
182
- '--',
183
- FRAMEWORK_DIR
184
- ]);
185
- return diff;
186
- } catch (error) {
187
- return '';
188
- }
189
- }
190
-
191
176
  // ────────────────────────────────────────────────────────────────────
192
177
  // SSOT update-status check (v3.25.0+).
193
178
  //
@@ -0,0 +1,225 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { parseMarkdown, mergeOverlay, computeBaseFileSha } = require('./overlay-merger');
4
+ const GitUtils = require('./git');
5
+ const { buildFrontmatter } = require('../commands/overlay');
6
+
7
+ /**
8
+ * Auto-capture + verify for the seamless `baldart update` flow (v4.8.0+).
9
+ *
10
+ * When the consumer has divergent edits to a framework file that are NOT yet
11
+ * preserved by an overlay, the seamless update would lose them on `--reset`.
12
+ * Rather than dead-end the user (the old behaviour), this module CAPTURES the
13
+ * edit into a populated overlay (section-marker model) and then VERIFIES that
14
+ * re-merging that overlay onto the FRESH upstream base reproduces the edit
15
+ * exactly at the section level. The verification is the safety net: if the
16
+ * capture cannot be reproduced faithfully (a section deletion, a frontmatter /
17
+ * preamble change, a mid-file insertion, a skill's runtime-concat model), the
18
+ * file is reported as a BLOCKER and the seamless flow stops once instead of
19
+ * silently mangling the customization.
20
+ *
21
+ * Only agents/commands are auto-capturable — they use the deterministic
22
+ * section-merge engine in overlay-merger.js, so the merge can be replayed and
23
+ * checked. Skills use the runtime-concat model (the CLI never merges them), so
24
+ * they are always blockers here.
25
+ *
26
+ * Pure-ish: writes overlay files only for VERIFIED captures; never throws.
27
+ */
28
+
29
+ // Consumer `.framework/<repo-content>` path → path inside the BALDART repo
30
+ // (i.e. inside FETCH_HEAD). The subtree prefix is `.framework`, so stripping it
31
+ // yields the upstream path: `framework/.claude/agents/x.md`.
32
+ function toRepoPath(p) {
33
+ return p.replace(/^\.framework\//, '');
34
+ }
35
+
36
+ // { kind, name } from a touched `.framework/.../.claude/(agents|commands|skills)/…`
37
+ // path, or null when the path isn't an overlay-able primitive.
38
+ function kindNameFromPath(p) {
39
+ const m = /(?:^|\/)\.framework\/framework\/\.claude\/(agents|commands|skills)\/(.+)$/.exec(p);
40
+ if (!m) return null;
41
+ const [, dir, rest] = m;
42
+ if (dir === 'skills') {
43
+ const name = rest.split('/')[0];
44
+ return name ? { kind: 'skill', name } : null;
45
+ }
46
+ const name = rest.replace(/\.md$/, '');
47
+ if (!name || name.includes('/')) return null;
48
+ return { kind: dir === 'agents' ? 'agent' : 'command', name };
49
+ }
50
+
51
+ // Normalize for content comparison: drop trailing whitespace per line and
52
+ // collapse trailing blank lines, then trim. The merge engine normalizes the
53
+ // same way (parseMarkdown / rebuildSections), so this makes verify robust to
54
+ // cosmetic whitespace without masking real content differences.
55
+ function norm(s) {
56
+ return (s || '').replace(/[ \t]+$/gm, '').replace(/\n{2,}$/g, '\n').trim();
57
+ }
58
+
59
+ // Build an overlay BODY (section markers) capturing the diff base→edited.
60
+ // Returns { body } on success or { blocker: <reason> } when the edit cannot be
61
+ // expressed by the overlay model.
62
+ function buildOverlayBody(baseContent, editedContent) {
63
+ const base = parseMarkdown(baseContent);
64
+ const edited = parseMarkdown(editedContent);
65
+
66
+ // The merge engine keeps the BASE frontmatter and BASE preamble verbatim, so
67
+ // a change to either cannot be captured by an overlay.
68
+ if (norm(base.frontmatter) !== norm(edited.frontmatter)) {
69
+ return { blocker: 'frontmatter-changed' };
70
+ }
71
+ if (norm(base.preamble) !== norm(edited.preamble)) {
72
+ return { blocker: 'preamble-changed' };
73
+ }
74
+
75
+ const editedHeadings = new Set(edited.sections.map((s) => s.heading));
76
+ // A deleted base section has no marker to express it.
77
+ for (const s of base.sections) {
78
+ if (s.heading && !editedHeadings.has(s.heading)) {
79
+ return { blocker: `section-deleted: "${s.heading}"` };
80
+ }
81
+ }
82
+
83
+ const baseByHeading = new Map(base.sections.map((s) => [s.heading, s]));
84
+ const blocks = [];
85
+ for (const s of edited.sections) {
86
+ const b = baseByHeading.get(s.heading);
87
+ if (!b) {
88
+ // New section → plain H2 (merge appends it as a trailing custom section).
89
+ blocks.push(`## ${s.heading}\n${s.body.replace(/\n+$/, '')}\n`);
90
+ } else if (norm(b.body) !== norm(s.body)) {
91
+ blocks.push(`## [OVERRIDE] ${s.heading}\n${s.body.replace(/\n+$/, '')}\n`);
92
+ }
93
+ }
94
+ if (blocks.length === 0) return { blocker: 'no-diff' };
95
+ return { body: '\n' + blocks.join('\n') };
96
+ }
97
+
98
+ // Replay the merge against the fresh base and check it reproduces the edit
99
+ // exactly (ordered sections + preamble, ignoring frontmatter and the generated
100
+ // marker comment). This is the contract behind the user's "auto-capture +
101
+ // verify" choice — capture is only trusted when it round-trips.
102
+ function verifyCapture({ kind, name, baseContent, overlayContent, editedContent, frameworkVersion }) {
103
+ let generated;
104
+ try {
105
+ ({ generated } = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion }));
106
+ } catch (_) {
107
+ return false;
108
+ }
109
+ const g = parseMarkdown(generated);
110
+ const e = parseMarkdown(editedContent);
111
+ if (g.sections.length !== e.sections.length) return false;
112
+ for (let i = 0; i < e.sections.length; i++) {
113
+ if (g.sections[i].heading !== e.sections[i].heading) return false;
114
+ if (norm(g.sections[i].body) !== norm(e.sections[i].body)) return false;
115
+ }
116
+ const gPre = norm(g.preamble.replace(/<!--\s*baldart-generated:[\s\S]*?-->/g, ''));
117
+ return gPre === norm(e.preamble);
118
+ }
119
+
120
+ /**
121
+ * Capture + verify EVERY overlay-able file touched by the divergent commits —
122
+ * both already-overlaid and not. The caller passes the full touched set (never
123
+ * a pre-filtered subset): an unmappable or unverifiable path must surface as a
124
+ * BLOCKER here, never be silently dropped (which a pre-filter would do, letting
125
+ * the reset wipe an edit with no overlay to preserve it).
126
+ *
127
+ * Per file:
128
+ * - not an overlay primitive / no overlay mapping → BLOCKER.
129
+ * - skill: runtime-concat — the CLI cannot merge or verify it. An EXISTING
130
+ * overlay carries the customization (re-applied at runtime) → trusted; NO
131
+ * overlay → BLOCKER (the edit would be lost).
132
+ * - agent/command: fully verifiable. An EXISTING overlay is VERIFIED to still
133
+ * reproduce the current edit (a stale overlay that predates a newer edit is
134
+ * a BLOCKER, not a silent loss). NO overlay → captured from the diff and
135
+ * verified; un-capturable (deletion / frontmatter / preamble change) →
136
+ * BLOCKER.
137
+ *
138
+ * @param {Object} args
139
+ * @param {string} args.cwd consumer repo root
140
+ * @param {Object} args.git GitUtils instance (uses .git.raw)
141
+ * @param {string[]} args.files consumer `.framework/…` paths
142
+ * @param {string} args.frameworkVersion the version being updated TO (remote)
143
+ * @returns {Promise<{ captured: string[], blockers: Array<{file:string,reason:string}> }>}
144
+ * `captured` = overlay rel-paths that preserve the edit (existing,
145
+ * verified, or newly written); `blockers` = files that could not be
146
+ * captured+verified.
147
+ */
148
+ async function captureAndVerify({ cwd, git, files, frameworkVersion }) {
149
+ const captured = [];
150
+ const blockers = [];
151
+ for (const file of [...new Set(files || [])]) {
152
+ const kn = kindNameFromPath(file);
153
+ if (!kn) { blockers.push({ file, reason: 'not-overlayable' }); continue; }
154
+ const overlayRel = GitUtils.overlayRelForFrameworkFile(file);
155
+ if (!overlayRel) { blockers.push({ file, reason: 'no-overlay-mapping' }); continue; }
156
+ const overlayAbs = path.join(cwd, overlayRel);
157
+ const overlayExists = fs.existsSync(overlayAbs);
158
+
159
+ if (kn.kind === 'skill') {
160
+ // Runtime-concat: the CLI never merges a skill overlay, so it cannot be
161
+ // verified. Trust an existing overlay (the customization lives there and
162
+ // is re-applied at runtime); a skill edit with no overlay would be lost.
163
+ if (overlayExists) captured.push(overlayRel);
164
+ else blockers.push({ file, reason: 'skill edit with no overlay — runtime-concat, cannot auto-capture; author one with /overlay' });
165
+ continue;
166
+ }
167
+
168
+ // agent / command — verifiable against the fresh upstream base.
169
+ let baseContent;
170
+ try { baseContent = await git.git.raw(['show', `FETCH_HEAD:${toRepoPath(file)}`]); }
171
+ catch (_) { blockers.push({ file, reason: 'no-upstream-base (new file?)' }); continue; }
172
+
173
+ let editedContent;
174
+ try { editedContent = fs.readFileSync(path.join(cwd, file), 'utf8'); }
175
+ catch (_) { blockers.push({ file, reason: 'edit unreadable on disk' }); continue; }
176
+
177
+ if (overlayExists) {
178
+ // Verify the EXISTING overlay still reproduces the current edit. An
179
+ // overlay authored before a newer `.framework` edit is stale — resetting
180
+ // would silently drop the delta, so stop instead.
181
+ let overlayContent;
182
+ try { overlayContent = fs.readFileSync(overlayAbs, 'utf8'); }
183
+ catch (_) { blockers.push({ file, reason: 'existing overlay unreadable' }); continue; }
184
+ if (verifyCapture({ kind: kn.kind, name: kn.name, baseContent, overlayContent, editedContent, frameworkVersion })) {
185
+ captured.push(overlayRel);
186
+ } else {
187
+ blockers.push({ file, reason: 'existing overlay does not reproduce your current edit (stale) — reconcile it' });
188
+ }
189
+ continue;
190
+ }
191
+
192
+ // No overlay yet — capture from the diff and verify the round-trip.
193
+ const built = buildOverlayBody(baseContent, editedContent);
194
+ if (built.blocker) { blockers.push({ file, reason: built.blocker }); continue; }
195
+
196
+ const baseSha = computeBaseFileSha(baseContent);
197
+ const overlayContent =
198
+ buildFrontmatter({ kind: kn.kind, name: kn.name, frameworkVersion, baseSha, mode: 'extend' })
199
+ + built.body;
200
+
201
+ if (!verifyCapture({ kind: kn.kind, name: kn.name, baseContent, overlayContent, editedContent, frameworkVersion })) {
202
+ blockers.push({ file, reason: 'verify-failed (merge would not reproduce the edit)' });
203
+ continue;
204
+ }
205
+
206
+ try {
207
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
208
+ fs.writeFileSync(overlayAbs, overlayContent);
209
+ captured.push(overlayRel);
210
+ } catch (err) {
211
+ blockers.push({ file, reason: `could not write overlay: ${err.message}` });
212
+ }
213
+ }
214
+ return { captured, blockers };
215
+ }
216
+
217
+ module.exports = {
218
+ captureAndVerify,
219
+ // exported for unit tests
220
+ buildOverlayBody,
221
+ verifyCapture,
222
+ kindNameFromPath,
223
+ toRepoPath,
224
+ norm,
225
+ };