baldart 4.6.0 → 4.8.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/CHANGELOG.md +28 -0
- package/README.md +23 -11
- package/VERSION +1 -1
- package/framework/.claude/commands/codexreview.md +14 -6
- package/framework/.claude/skills/baldart-update/SKILL.md +112 -163
- package/framework/.claude/skills/new/SKILL.md +36 -16
- package/package.json +1 -1
- package/src/commands/overlay.js +1 -1
- package/src/commands/update.js +261 -126
- package/src/utils/__tests__/overlay-capture.test.js +215 -0
- package/src/utils/git.js +0 -15
- package/src/utils/overlay-capture.js +225 -0
|
@@ -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
|
+
};
|