baldart 5.10.0 → 5.11.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
CHANGED
|
@@ -5,6 +5,29 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.11.0] - 2026-07-06
|
|
9
|
+
|
|
10
|
+
**Seamless framework-drift reconciliation in `baldart update` — a trivially-drifted shared framework file no longer dead-ends the whole update.**
|
|
11
|
+
A `mayo` consumer stuck at v5.7.0→v5.9.0 surfaced the failure: five ancient `.framework/` commits (an add-then-revert of the v4.77.0 extractor, a superseded responsive-prose delete, a FEAT-0054-03 gating feature) were all flagged `custom-other` → class `mixed`/`real-custom` → **hard block**. Root cause, proven by diffing HEAD vs upstream file-by-file: of the **14** framework files those commits touched, **13 were already byte-identical to upstream** — only ONE, `design-system-protocol.md`, still diverged, and its *entire* divergence was **2 cosmetic characters** (a `+ ` vs `- ` markdown continuation bullet, a merge artifact). Because `isAbsorbedAgainstUpstream` is **all-or-nothing per commit** (a commit demotes to `absorbed` only when EVERY touched payload file matches upstream), that single 2-char drift in a file all five commits share poisoned the whole set. Five commits blocked by two cosmetic characters, on every release.
|
|
12
|
+
|
|
13
|
+
The fix encodes BALDART's own contract — **direct `.framework/` edits are blocked by the `framework-edit-gate` and sanctioned customizations live in overlays, so an uncovered residual is drift upstream OWNS** — into three additive classifier/updater pieces (no new agent, no new file):
|
|
14
|
+
|
|
15
|
+
- **New `liveDriftAgainstUpstream(touched)` + `reconcilable` category** (`src/utils/git.js` `classifyDivergence`). A would-be `custom-other` commit whose SURVIVING drift (`absorbablePayloadSubset` with HEAD≠upstream, computed by cheap blob-SHA compare) is confined to framework-owned files that (a) still exist upstream and (b) are NOT captured by any existing overlay is demoted from `custom-other` to `reconcilable`, carrying the exact file list. Overlay-COVERED drift routes to the capture path (intentional, tracked); net-new/deleted framework files keep flagging as real drift (an overwrite can't cleanly reconcile them).
|
|
16
|
+
- **Revert-pair cancellation** (`GitUtils.cancelRevertPairs`, pure/static). A `Revert "<subject>"` commit and the commit it reverts are a net no-op on the tree — both demote to `absorbed`. Kills the add-then-revert class that re-triggered the gate every release.
|
|
17
|
+
- **Seamless auto-align in `update`** (`src/commands/update.js`). New non-blocking `reconcilable` divergence branch: it lists the framework-owned files that drift, aligns each to its upstream (FETCH_HEAD) blob via the new `reconcileFrameworkDrift` helper and commits ONCE (so the subtree pull has no local hunk to conflict on), then proceeds — with the pre-pull backup tag making it fully recoverable. `git.on_divergence: abort` still lets a cautious consumer opt out; a genuine `custom-other` blocker (net-new/deleted/overlay-covered) still dominates to `mixed`/`real-custom` and stops for `/baldart-push` or `--on-divergence pull`.
|
|
18
|
+
- **`aggregateDivergenceClass`** gains the `reconcilable` class (blockers dominate → `mixed`/`real-custom`; else overlay-able → `overlay-able`; else reconcilable → `reconcilable`; else `all-noise`). `doctor`/`version` need no change (they delegate to `update`, which now self-heals the drift).
|
|
19
|
+
- Tests: `src/utils/__tests__/classify-divergence.test.js` gains 5 `reconcilable` aggregate fixtures + 4 `cancelRevertPairs` fixtures (61 pass, 0 fail). End-to-end validated on a synthetic subtree consumer reproducing the exact cosmetic-drift cascade → class flips from blocking to `reconcilable`, and `reconcileFrameworkDrift` aligns the file byte-for-byte to upstream.
|
|
20
|
+
- **Codex parity: portable** — pure CLI (Node), runtime-agnostic; benefits `[claude]`, `[codex]`, and `[claude, codex]` consumers identically. **No new `baldart.config.yml` key** (rides on the existing `git.on_divergence` policy) → the schema-change propagation rule does NOT apply.
|
|
21
|
+
|
|
22
|
+
## [5.10.1] - 2026-07-06
|
|
23
|
+
|
|
24
|
+
**Per-wave Codex relay backported to the v5.6.0 blocking-foreground pattern — no more bogus TIMEOUT at 83s that kills a live Codex.**
|
|
25
|
+
The v5.6.0 FEAT-0068 fix (final-review relay: FOREGROUND blocking Bash call with `timeout: 600000`, no background+poll) was never backported to the **per-wave** relay in `new-card-review.js`, so `/new`'s Discovery phase still ran the old `run_in_background:true` + poll relay. A live FEAT-0069 run reproduced the exact failure: the structured-output enforcement force-answered the polling relay at ~83s of its instructed 10-minute window (an idle poll turn is a forceable turn), it returned `marker:"TIMEOUT"` while Codex was still executing, and the Codex process died with the relay → cold `code-reviewer` fallback for that wave with no cross-model diversity. Diagnosed by a parallel budget-verification + adversarial-refutation pass (the refutation killed the tempting "launch-in-background-and-don't-poll" redesign: a dynamic-workflow script has no Bash, so Codex is always a child of the launcher agent — returning early *guarantees* the kill; the poll is what keeps it alive, so the fix is a **blocking** call, not a non-polling one).
|
|
26
|
+
|
|
27
|
+
- **`new-card-review.js`** — the per-wave `codexPrompt` now mirrors `new-final-review.js:343-351` verbatim (adjusted only for the wave-scoped `/tmp` file names): a single FOREGROUND `node … task … --wait` call with the Bash tool `timeout` at 600000ms, `marker` derived by grepping the file *after* the call returns, `RELAY_EXIT` 124/137/143 → real `TIMEOUT` + a worktree-scoped orphan reap (`pkill -f -- "--cwd <worktree>"`, never a bare pkill), clean exit with no sentinel → `TURN_COMPLETED`. The header comment is corrected (no longer "polls"). The fan-in (awk extract, prose salvage, deterministic re-extract, `code-reviewer` fallback) is unchanged.
|
|
28
|
+
- **Effect**: Codex now gets its full ~10-minute Bash-timeout budget per wave instead of dying at 83s; a genuinely slow/hung Codex still degrades cleanly (real timeout → reap → fallback), so the graceful-degradation contract is preserved.
|
|
29
|
+
- **Codex parity: N/A** — dynamic workflows are Claude-only (`codex.js` returns `null` for `workflowsDir()`); Codex consumers use the skill's inline review fallback, which was never affected. No behaviour change for Codex, no new `baldart.config.yml` key → the schema-change propagation rule does NOT apply.
|
|
30
|
+
|
|
8
31
|
## [5.10.0] - 2026-07-06
|
|
9
32
|
|
|
10
33
|
**Dedicated `code-simplifier` agent + `simplify-protocol.md` module + a deterministic clone floor — the hyper-specialized reuse & simplicity layer that catches the agentic defects generic linters miss (reuse-misses, duplication, dead code, wrong-altitude abstractions, missed optimizations).**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.11.0
|
|
@@ -331,7 +331,9 @@ log(codexResolved
|
|
|
331
331
|
? `Codex companion resolved deterministically: ${codexScriptPath}`
|
|
332
332
|
: 'Codex companion NOT found (deterministic pre-flight) — primary code review via code-reviewer fallback.')
|
|
333
333
|
|
|
334
|
-
// Codex companion review. The relay LAUNCHES Codex
|
|
334
|
+
// Codex companion review. The relay LAUNCHES Codex as a BLOCKING foreground call (v5.10.1 — no more
|
|
335
|
+
// background+poll: the poll's idle turn let the structured-output enforcement force a bogus TIMEOUT at
|
|
336
|
+
// 83s while Codex was still running, killing it → cold fallback) and returns the RAW stdout of the
|
|
335
337
|
// extraction commands — it does NOT review code, does NOT re-grep, and (since v4.56.3) does NOT
|
|
336
338
|
// decide "did Codex succeed": the JS fan-in parses jsonBlock itself. Rationale: the relay model is
|
|
337
339
|
// NOT guaranteeable — `model:'sonnet'` is a request the runtime silently downgrades to Haiku under
|
|
@@ -360,13 +362,15 @@ const codexPrompt =
|
|
|
360
362
|
`The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
|
|
361
363
|
`Run EXACTLY these steps and nothing else:\n` +
|
|
362
364
|
` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
|
|
363
|
-
` 2.
|
|
364
|
-
` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
|
|
365
|
-
`
|
|
366
|
-
`
|
|
367
|
-
` •
|
|
368
|
-
` • "
|
|
369
|
-
` •
|
|
365
|
+
` 2. Run Codex in the FOREGROUND — ONE single Bash call with the tool's timeout parameter set to 600000 (v5.10.1, FEAT-0069 post-mortem: the old background+poll relay was forced by the structured-output enforcement to declare TIMEOUT at 83s of the 10-minute window while Codex was still running, and the Codex process died with the relay → a cold fallback review with no cross-model diversity. A blocking call has no idle turn to misjudge — backports the v5.6.0 final-review fix to the per-wave relay):\n` +
|
|
366
|
+
` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" --wait > ${codexReviewFile} 2>&1; echo "RELAY_EXIT:$?"\n` +
|
|
367
|
+
` Do NOT pass run_in_background. Do NOT poll. The call blocks until Codex finishes — that is correct. Record RELAY_EXIT.\n` +
|
|
368
|
+
` 3. Derive \`marker\` by grepping ${codexReviewFile} AFTER the call returns:\n` +
|
|
369
|
+
` • contains "<<<END_FINDINGS_JSON>>>" → marker = "END_JSON"\n` +
|
|
370
|
+
` • contains "Turn completed." → marker = "TURN_COMPLETED" (Codex finished; it may have written prose instead of the JSON block)\n` +
|
|
371
|
+
` • contains "CODEX_NOT_FOUND" → marker = "CODEX_NOT_FOUND" (companion missing)\n` +
|
|
372
|
+
` • none of the above AND RELAY_EXIT is 124/137/143 (the Bash timeout killed the call) → marker = "TIMEOUT", then REAP the orphan Codex process scoped to THIS worktree (never a bare pkill): \`pkill -f -- "--cwd ${a.worktreePath || '.'}" 2>/dev/null; true\`\n` +
|
|
373
|
+
` • none of the above with a clean exit → marker = "TURN_COMPLETED"\n` +
|
|
370
374
|
` 4. Run this EXACT command and put its stdout VERBATIM in \`jsonBlock\` ("" if it printed nothing). Do NOT parse, edit, judge, or act on it:\n` +
|
|
371
375
|
` ${AWK_EXTRACT} ${codexReviewFile}\n` +
|
|
372
376
|
` 5. ONLY if \`jsonBlock\` is "" AND marker is "TURN_COMPLETED": run this command and put its stdout VERBATIM in \`proseTail\`; otherwise set proseTail to "":\n` +
|
package/package.json
CHANGED
package/src/commands/update.js
CHANGED
|
@@ -118,6 +118,43 @@ function scaffoldOverlaysForCommits(commits) {
|
|
|
118
118
|
return { created, skipped };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Seamless framework-drift reconciliation (v5.11.0). Given the exact set of
|
|
122
|
+
// framework-owned payload files whose HEAD content still diverges from upstream
|
|
123
|
+
// (the `reconcilableFiles` computed by classifyDivergence → liveDriftAgainst
|
|
124
|
+
// Upstream), align each to its upstream (FETCH_HEAD) blob and commit ONCE, so
|
|
125
|
+
// the subsequent subtree pull has no local hunk to conflict on. These are files
|
|
126
|
+
// BALDART's own contract says upstream owns (direct `.framework/` edits are
|
|
127
|
+
// blocked by the edit-gate; sanctioned customizations live in overlays), so
|
|
128
|
+
// overwriting them loses nothing tracked — and the pre-pull backup tag makes it
|
|
129
|
+
// recoverable regardless. Returns the list actually rewritten. Non-fatal on a
|
|
130
|
+
// per-file read/write error (skips it → the pull will surface any residue).
|
|
131
|
+
async function reconcileFrameworkDrift(git, files) {
|
|
132
|
+
const fs = require('fs');
|
|
133
|
+
const path = require('path');
|
|
134
|
+
const cwd = process.cwd();
|
|
135
|
+
const written = [];
|
|
136
|
+
for (const p of files) {
|
|
137
|
+
// p is a repo-relative `.framework/<rest>` path; upstream (FETCH_HEAD) has no
|
|
138
|
+
// `.framework/` prefix. Read the upstream blob and overwrite the working file.
|
|
139
|
+
const upstreamPath = p.replace(/^\.framework\//, '');
|
|
140
|
+
try {
|
|
141
|
+
const blob = await git.git.raw(['show', `FETCH_HEAD:${upstreamPath}`]);
|
|
142
|
+
fs.writeFileSync(path.join(cwd, p), blob);
|
|
143
|
+
written.push(p);
|
|
144
|
+
} catch (_) { /* skip — pull will reconcile or surface it */ }
|
|
145
|
+
}
|
|
146
|
+
if (written.length) {
|
|
147
|
+
try {
|
|
148
|
+
await git.git.add(written);
|
|
149
|
+
const st = await git.git.status();
|
|
150
|
+
if (st.staged.length) {
|
|
151
|
+
await git.git.commit('chore(baldart): align framework-owned drift to upstream (pre-pull reconcile)');
|
|
152
|
+
}
|
|
153
|
+
} catch (_) { /* non-fatal — files remain aligned on disk for the pull */ }
|
|
154
|
+
}
|
|
155
|
+
return written;
|
|
156
|
+
}
|
|
157
|
+
|
|
121
158
|
// Path prefixes BALDART itself writes during install/update. Anything outside
|
|
122
159
|
// these patterns is treated as user-owned and never auto-staged.
|
|
123
160
|
//
|
|
@@ -1077,6 +1114,27 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1077
1114
|
}
|
|
1078
1115
|
if (status.divergenceClass === 'all-noise') {
|
|
1079
1116
|
UI.info(`Detected ${status.divergenceCommits.length} subtree-merge / chore commit(s) (git plumbing noise — no real content drift). Auto-resolving…`);
|
|
1117
|
+
} else if (status.divergenceClass === 'reconcilable') {
|
|
1118
|
+
// Only framework-owned residual drift, uncovered by any overlay → upstream
|
|
1119
|
+
// owns these files. Align them to upstream BEFORE the pull so the update is
|
|
1120
|
+
// seamless instead of dead-ending. The backup tag (created below) + the
|
|
1121
|
+
// shown file list keep it transparent and recoverable. `--on-divergence
|
|
1122
|
+
// abort` still lets a cautious user opt out.
|
|
1123
|
+
const reconcileFiles = [...new Set(
|
|
1124
|
+
status.divergenceCommits.flatMap((c) => c.reconcilableFiles || [])
|
|
1125
|
+
)].sort();
|
|
1126
|
+
UI.warning(`Detected ${reconcileFiles.length} framework-owned file(s) that drift from upstream but carry no overlay — upstream owns them.`);
|
|
1127
|
+
for (const f of reconcileFiles) UI.info(` • ${f}`);
|
|
1128
|
+
if (divStrategy === 'abort') {
|
|
1129
|
+
UI.info('Update cancelled (--on-divergence abort). The drift is left untouched.');
|
|
1130
|
+
emitUpdateJson({ ok: false, action: 'aborted',
|
|
1131
|
+
divergence_class: status.divergenceClass, reconcile_files: reconcileFiles,
|
|
1132
|
+
reason: 'Reconcilable framework drift left untouched (--on-divergence abort).' }, 0);
|
|
1133
|
+
}
|
|
1134
|
+
UI.info('Aligning them to upstream (pre-pull reconcile) — nothing tracked is lost; a backup tag is created below…');
|
|
1135
|
+
const written = await reconcileFrameworkDrift(git, reconcileFiles);
|
|
1136
|
+
UI.success(`Aligned ${written.length} framework file(s) to upstream. Proceeding with the update.`);
|
|
1137
|
+
// fall through to the normal preview + pull
|
|
1080
1138
|
} else if (status.divergenceClass === 'overlay-able') {
|
|
1081
1139
|
UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
|
|
1082
1140
|
UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
|
|
@@ -1131,7 +1189,7 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1131
1189
|
}
|
|
1132
1190
|
} else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
|
|
1133
1191
|
UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
|
|
1134
|
-
for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
|
|
1192
|
+
for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper', 'absorbed', 'reconcilable'].includes(c.category))) {
|
|
1135
1193
|
UI.info(` • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
|
|
1136
1194
|
}
|
|
1137
1195
|
const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');
|
|
@@ -121,6 +121,82 @@ const AGGREGATE_FIXTURES = [
|
|
|
121
121
|
],
|
|
122
122
|
expected: 'overlay-able',
|
|
123
123
|
},
|
|
124
|
+
// ─── reconcilable (v5.11.0): framework-owned drift, no overlay → seamless ──
|
|
125
|
+
{
|
|
126
|
+
name: 'reconcilable alone → reconcilable (framework-owned, non-blocking)',
|
|
127
|
+
commits: [{ category: 'reconcilable' }],
|
|
128
|
+
expected: 'reconcilable',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'reconcilable + plumbing noise → reconcilable',
|
|
132
|
+
commits: [
|
|
133
|
+
{ category: 'reconcilable' },
|
|
134
|
+
{ category: 'subtree-merge' },
|
|
135
|
+
{ category: 'absorbed' },
|
|
136
|
+
],
|
|
137
|
+
expected: 'reconcilable',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'reconcilable + custom-other → real-custom (blocker dominates)',
|
|
141
|
+
commits: [
|
|
142
|
+
{ category: 'reconcilable' },
|
|
143
|
+
{ category: 'custom-other' },
|
|
144
|
+
],
|
|
145
|
+
expected: 'real-custom',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'reconcilable + overlay-able → overlay-able (overlay path wins)',
|
|
149
|
+
commits: [
|
|
150
|
+
{ category: 'reconcilable' },
|
|
151
|
+
{ category: 'custom-overlay-able' },
|
|
152
|
+
],
|
|
153
|
+
expected: 'overlay-able',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'reconcilable + overlay-able + custom-other → mixed (blocker + overlay)',
|
|
157
|
+
commits: [
|
|
158
|
+
{ category: 'reconcilable' },
|
|
159
|
+
{ category: 'custom-overlay-able' },
|
|
160
|
+
{ category: 'custom-other' },
|
|
161
|
+
],
|
|
162
|
+
expected: 'mixed',
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
// cancelRevertPairs(commits) — mutating: a `Revert "<subject>"` commit and its
|
|
167
|
+
// target both demote to `absorbed` (net-zero tree effect). Only custom-* are
|
|
168
|
+
// touched; noise categories and unmatched reverts are left as-is.
|
|
169
|
+
const REVERT_PAIR_FIXTURES = [
|
|
170
|
+
{
|
|
171
|
+
name: 'add + its Revert → both absorbed',
|
|
172
|
+
commits: [
|
|
173
|
+
{ subject: '[CHORE] add extractor', category: 'reconcilable' },
|
|
174
|
+
{ subject: 'Revert "[CHORE] add extractor"', category: 'reconcilable' },
|
|
175
|
+
],
|
|
176
|
+
expected: ['absorbed', 'absorbed'],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'revert with no matching target → left unchanged',
|
|
180
|
+
commits: [
|
|
181
|
+
{ subject: 'Revert "something not here"', category: 'custom-other' },
|
|
182
|
+
],
|
|
183
|
+
expected: ['custom-other'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'target is noise → only the custom revert is demoted',
|
|
187
|
+
commits: [
|
|
188
|
+
{ subject: 'chore(baldart): x', category: 'chore-wrapper' },
|
|
189
|
+
{ subject: 'Revert "chore(baldart): x"', category: 'custom-other' },
|
|
190
|
+
],
|
|
191
|
+
expected: ['chore-wrapper', 'absorbed'],
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'non-revert commits untouched',
|
|
195
|
+
commits: [
|
|
196
|
+
{ subject: 'feat: real work', category: 'custom-other' },
|
|
197
|
+
],
|
|
198
|
+
expected: ['custom-other'],
|
|
199
|
+
},
|
|
124
200
|
];
|
|
125
201
|
|
|
126
202
|
const PATH_FIXTURES = [
|
|
@@ -247,6 +323,16 @@ for (const f of AGGREGATE_FIXTURES) {
|
|
|
247
323
|
}
|
|
248
324
|
}
|
|
249
325
|
|
|
326
|
+
console.log('\n─── Revert-pair cancellation ───');
|
|
327
|
+
for (const f of REVERT_PAIR_FIXTURES) {
|
|
328
|
+
const commits = f.commits.map((c) => ({ ...c }));
|
|
329
|
+
GitUtils.cancelRevertPairs(commits);
|
|
330
|
+
const actual = commits.map((c) => c.category);
|
|
331
|
+
const ok = actual.length === f.expected.length && actual.every((v, i) => v === f.expected[i]);
|
|
332
|
+
if (ok) { pass++; console.log(` ✓ ${f.name} → [${actual.join(', ')}]`); }
|
|
333
|
+
else { fail++; console.log(` ✗ ${f.name} → [${actual.join(', ')}] (expected [${f.expected.join(', ')}])`); }
|
|
334
|
+
}
|
|
335
|
+
|
|
250
336
|
console.log('\n─── Overlay path mapping ───');
|
|
251
337
|
for (const f of OVERLAY_REL_FIXTURES) {
|
|
252
338
|
const actual = GitUtils.overlayRelForFrameworkFile(f.file);
|
package/src/utils/git.js
CHANGED
|
@@ -218,13 +218,21 @@ class GitUtils {
|
|
|
218
218
|
// live drift → noise.
|
|
219
219
|
// - custom-overlay-able: user edited a framework agent/skill/command
|
|
220
220
|
// → should migrate to .baldart/overlays/
|
|
221
|
-
// - custom-other
|
|
221
|
+
// - reconcilable: a would-be custom-other whose SURVIVING drift is confined
|
|
222
|
+
// to framework-owned payload files that exist upstream and
|
|
223
|
+
// are NOT captured by any overlay → upstream OWNS them;
|
|
224
|
+
// `update` aligns them to upstream then pulls (see
|
|
225
|
+
// liveDriftAgainstUpstream). Non-blocking.
|
|
226
|
+
// - custom-other: anything else that still carries genuine live drift
|
|
227
|
+
// (net-new/deleted framework files, overlay-covered edits)
|
|
222
228
|
//
|
|
223
229
|
// Aggregate `class`:
|
|
224
230
|
// - all-noise: every commit is subtree-* / chore-wrapper / absorbed → auto-resolve
|
|
225
231
|
// - overlay-able: every non-noise commit is overlay-able → suggest /overlay
|
|
232
|
+
// - reconcilable: non-noise commits are only framework-owned reconcilable
|
|
233
|
+
// drift → align-to-upstream then pull (seamless, non-blocking)
|
|
226
234
|
// - real-custom: every non-noise commit is custom-other → prompt 3-way
|
|
227
|
-
// - mixed: non-noise commits span
|
|
235
|
+
// - mixed: non-noise commits span overlay-able + custom-other → prompt 3-way
|
|
228
236
|
//
|
|
229
237
|
// Default-conservative: any commit whose subject + touched-paths don't
|
|
230
238
|
// match a known pattern is classified `custom-other`, never auto-resolved.
|
|
@@ -357,6 +365,32 @@ class GitUtils {
|
|
|
357
365
|
return true;
|
|
358
366
|
}
|
|
359
367
|
|
|
368
|
+
// The subset of a commit's touched `.framework/` payload files whose current
|
|
369
|
+
// HEAD content STILL diverges from upstream (FETCH_HEAD) — i.e. the surviving
|
|
370
|
+
// "live drift" the subtree pull would otherwise have to merge. Restricted to
|
|
371
|
+
// the `absorbablePayloadSubset` (consumer files + subtree bookkeeping
|
|
372
|
+
// excluded — see there). A file is INCLUDED only when it exists BOTH at HEAD
|
|
373
|
+
// and upstream and their blob SHAs differ; a file that is net-new locally
|
|
374
|
+
// (absent upstream) or deleted at HEAD is EXCLUDED here (it cannot be cleanly
|
|
375
|
+
// reconciled by an overwrite-with-upstream, so it must keep flagging as real
|
|
376
|
+
// drift). Used to decide the `reconcilable` category. Cheap — compares SHAs.
|
|
377
|
+
async liveDriftAgainstUpstream(touched) {
|
|
378
|
+
const prefix = `${FRAMEWORK_DIR}/`;
|
|
379
|
+
const payload = GitUtils.absorbablePayloadSubset(touched);
|
|
380
|
+
const drift = [];
|
|
381
|
+
for (const p of payload) {
|
|
382
|
+
const upstreamPath = p.slice(prefix.length);
|
|
383
|
+
let headSha;
|
|
384
|
+
let upSha;
|
|
385
|
+
try { headSha = (await this.git.raw(['rev-parse', `HEAD:${p}`])).trim(); }
|
|
386
|
+
catch (_) { continue; } // deleted at HEAD → not overwrite-reconcilable
|
|
387
|
+
try { upSha = (await this.git.raw(['rev-parse', `FETCH_HEAD:${upstreamPath}`])).trim(); }
|
|
388
|
+
catch (_) { continue; } // net-new local (absent upstream) → not reconcilable
|
|
389
|
+
if (headSha && upSha && headSha !== upSha) drift.push(p);
|
|
390
|
+
}
|
|
391
|
+
return drift;
|
|
392
|
+
}
|
|
393
|
+
|
|
360
394
|
async classifyDivergence() {
|
|
361
395
|
// Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
|
|
362
396
|
const commits = [];
|
|
@@ -407,19 +441,69 @@ class GitUtils {
|
|
|
407
441
|
&& await this.isAbsorbedAgainstUpstream(touched)) {
|
|
408
442
|
category = 'absorbed';
|
|
409
443
|
}
|
|
444
|
+
// Seamless framework-drift reconciliation (v5.11.0). A `custom-other`
|
|
445
|
+
// commit that is NOT fully absorbed but whose SURVIVING drift is confined
|
|
446
|
+
// to framework-owned payload files that (a) still exist upstream and (b)
|
|
447
|
+
// are NOT captured by any existing overlay is, by BALDART's own contract,
|
|
448
|
+
// overwrite-reconcilable: direct `.framework/` edits are blocked by the
|
|
449
|
+
// `framework-edit-gate` and sanctioned customizations live in overlays, so
|
|
450
|
+
// an uncovered residual is drift upstream OWNS. Demote it to `reconcilable`
|
|
451
|
+
// + carry the exact file list so `update` can align those files to upstream
|
|
452
|
+
// BEFORE the pull instead of dead-ending the whole update. This kills the
|
|
453
|
+
// cascade where ONE trivially-drifted shared file (e.g. a cosmetic 2-char
|
|
454
|
+
// diff in `design-system-protocol.md`) poisons every commit that touches it
|
|
455
|
+
// into `custom-other`. Overlay-COVERED drift routes to the capture path
|
|
456
|
+
// instead (intentional, tracked); net-new/deleted files keep flagging.
|
|
457
|
+
let reconcilableFiles = [];
|
|
458
|
+
if (category === 'custom-other') {
|
|
459
|
+
const drift = await this.liveDriftAgainstUpstream(touched);
|
|
460
|
+
const anyCovered = drift.some((p) => {
|
|
461
|
+
const rel = GitUtils.overlayRelForFrameworkFile(p);
|
|
462
|
+
return rel ? fs.existsSync(path.join(this.cwd, rel)) : false;
|
|
463
|
+
});
|
|
464
|
+
if (drift.length && !anyCovered) {
|
|
465
|
+
category = 'reconcilable';
|
|
466
|
+
reconcilableFiles = drift;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
410
469
|
const overlayCovered = category === 'custom-overlay-able'
|
|
411
470
|
? this.overlayCoversTouched(touched)
|
|
412
471
|
: false;
|
|
413
|
-
commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered });
|
|
472
|
+
commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered, reconcilableFiles });
|
|
414
473
|
}
|
|
415
474
|
} catch (_) {
|
|
416
475
|
return { class: 'unknown', commits: [] };
|
|
417
476
|
}
|
|
418
477
|
|
|
419
478
|
if (commits.length === 0) return { class: 'all-noise', commits: [] };
|
|
479
|
+
// Revert-pair cancellation: a `Revert "<subject>"` commit and the commit it
|
|
480
|
+
// reverts are a net no-op on the tree — neither contributes live drift, so
|
|
481
|
+
// both are noise. Detect by subject (git's default revert subject) and demote
|
|
482
|
+
// the pair to `absorbed`. Guards a long-lived add-then-revert of a framework
|
|
483
|
+
// file from re-triggering the divergence gate on every release.
|
|
484
|
+
GitUtils.cancelRevertPairs(commits);
|
|
420
485
|
return { class: GitUtils.aggregateDivergenceClass(commits), commits };
|
|
421
486
|
}
|
|
422
487
|
|
|
488
|
+
// Pure: demote reverted+revert commit pairs to `absorbed` in place. A default
|
|
489
|
+
// git revert of "<subject>" produces the subject `Revert "<subject>"`; when
|
|
490
|
+
// both the revert and its target appear in the divergence range, their net
|
|
491
|
+
// tree effect is zero. Only demotes commits that are still `custom-*` (never
|
|
492
|
+
// touches genuine noise categories). Extracted static for unit-testing.
|
|
493
|
+
static cancelRevertPairs(commits) {
|
|
494
|
+
const custom = new Set(['custom-overlay-able', 'custom-other', 'reconcilable']);
|
|
495
|
+
const bySubject = new Map();
|
|
496
|
+
for (const c of commits) bySubject.set(c.subject, c);
|
|
497
|
+
for (const c of commits) {
|
|
498
|
+
const m = /^Revert "(.+)"$/.exec(c.subject || '');
|
|
499
|
+
if (!m) continue;
|
|
500
|
+
const target = bySubject.get(m[1]);
|
|
501
|
+
if (!target) continue;
|
|
502
|
+
if (custom.has(c.category)) { c.category = 'absorbed'; c.reconcilableFiles = []; }
|
|
503
|
+
if (custom.has(target.category)) { target.category = 'absorbed'; target.reconcilableFiles = []; }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
423
507
|
// Pure aggregation of per-commit categories into the divergence class.
|
|
424
508
|
// Extracted as a static method so it can be unit-tested without a live repo
|
|
425
509
|
// (see src/utils/__tests__/classify-divergence.test.js). Noise categories
|
|
@@ -431,8 +515,15 @@ class GitUtils {
|
|
|
431
515
|
if (nonNoise.length === 0) return 'all-noise';
|
|
432
516
|
const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
|
|
433
517
|
const hasOther = nonNoise.some((c) => c.category === 'custom-other');
|
|
434
|
-
|
|
435
|
-
|
|
518
|
+
const hasReconcilable = nonNoise.some((c) => c.category === 'reconcilable');
|
|
519
|
+
// A genuine `custom-other` blocker always dominates — it needs `/baldart-push`
|
|
520
|
+
// or an explicit `--on-divergence pull` (reconcilable commits ride along and
|
|
521
|
+
// are handled by the pull). Overlay-able edits route to the capture path.
|
|
522
|
+
if (hasOther) return hasOverlayable ? 'mixed' : 'real-custom';
|
|
523
|
+
if (hasOverlayable) return 'overlay-able';
|
|
524
|
+
// No blockers, no overlay-able: only framework-owned reconcilable drift →
|
|
525
|
+
// `update` aligns those files to upstream, then pulls. Seamless, non-blocking.
|
|
526
|
+
if (hasReconcilable) return 'reconcilable';
|
|
436
527
|
return 'real-custom';
|
|
437
528
|
}
|
|
438
529
|
|