From 64d7dc5da05ea74bf36fceea7819f5d80df0a436 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 19 Aug 2026 04:37:29 +0800 Subject: [PATCH] fix(install): merge state across selective installs --- scripts/lib/install/claude-skill-migration.js | 46 ++++++------- .../install-claude-skill-migration.test.js | 66 +++++++++++++++++++ 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/scripts/lib/install/claude-skill-migration.js b/scripts/lib/install/claude-skill-migration.js index ba22978be..1b82f0629 100644 --- a/scripts/lib/install/claude-skill-migration.js +++ b/scripts/lib/install/claude-skill-migration.js @@ -133,19 +133,13 @@ function isManagedOperation(operation) { } function uniqueOperations(operations) { - const seen = new Set(); - return operations.filter(operation => { - const key = [ - operation.kind, - normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath, - comparablePath(operation.destinationPath), - ].join('\0'); - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); + const byDestination = new Map(); + for (const operation of operations) { + // A target path has one current owner. Later operations come from the + // newest plan and replace stale metadata for the same destination. + byDestination.set(comparablePath(operation.destinationPath), operation); + } + return [...byDestination.values()]; } function buildState(statePreview, operations) { @@ -236,14 +230,18 @@ function createFileConflictWarning(destinationPath, retainsLegacy) { return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`; } -function createDisabledMigration(plan) { +function createDisabledMigration(plan, previousState) { + const finalState = buildState(plan.statePreview, [ + ...((previousState && previousState.operations) || []), + ...plan.statePreview.operations, + ]); return { enabled: false, appliedOperations: [...plan.operations], skippedOperations: [], warnings: [], - bridgeState: plan.statePreview, - finalState: plan.statePreview, + bridgeState: finalState, + finalState, legacyOperationsToRemove: [], requiresBridgeState: false, }; @@ -331,11 +329,16 @@ function buildMigrationStates(plan, previousState, previous, classification) { const legacyOperationsToRemove = legacyOperations.filter(operation => ( !retainedLegacyOperations.has(operation) )); + const removedLegacyDestinations = new Set( + legacyOperationsToRemove.map(operation => comparablePath(operation.destinationPath)) + ); const finalOperations = [ + ...((previousState && previousState.operations) || []).filter(operation => ( + !removedLegacyDestinations.has(comparablePath(operation.destinationPath)) + )), ...plan.statePreview.operations.filter(operation => ( !skippedDestinations.has(comparablePath(operation.destinationPath)) )), - ...retainedLegacyOperations, ]; const bridgeOperations = [ ...((previousState && previousState.operations) || []), @@ -352,14 +355,13 @@ function buildMigrationStates(plan, previousState, previous, classification) { } function prepareClaudeSkillMigration(plan) { - const target = plan && plan.adapter && plan.adapter.target; - if (!CLAUDE_TARGETS.has(target)) { - return createDisabledMigration(plan); - } - const previousState = pathExists(plan.installStatePath) ? readInstallState(plan.installStatePath) : null; + const target = plan && plan.adapter && plan.adapter.target; + if (!CLAUDE_TARGETS.has(target)) { + return createDisabledMigration(plan, previousState); + } const currentGroups = groupCurrentSkillOperations(plan); const previous = classifyPreviousOperations(plan, previousState); const classification = classifySkillConflicts(currentGroups, previous); diff --git a/tests/lib/install-claude-skill-migration.test.js b/tests/lib/install-claude-skill-migration.test.js index c9a2ab582..3f2d3d9f2 100644 --- a/tests/lib/install-claude-skill-migration.test.js +++ b/tests/lib/install-claude-skill-migration.test.js @@ -433,6 +433,72 @@ function runTests() { } })) passed++; else failed++; + if (test('merges managed operations across selective installs for the same target', () => { + const fixture = createFixture(); + try { + applyInstallPlan(fixture.plan); + + const extraSourceRelativePath = path.join('skills', 'extra-skill', 'SKILL.md'); + const extraSourcePath = path.join(fixture.sourceRoot, extraSourceRelativePath); + const extraDestinationPath = path.join( + fixture.targetRoot, + 'skills', + 'extra-skill', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(extraSourcePath), { recursive: true }); + fs.writeFileSync(extraSourcePath, '# Extra ECC skill\n'); + const extraOperation = createOperation( + 'skill-extra', + fixture.sourceRoot, + extraSourceRelativePath, + extraDestinationPath + ); + const extraPlan = { + ...fixture.plan, + operations: [extraOperation], + statePreview: { + ...fixture.plan.statePreview, + request: { + ...fixture.plan.statePreview.request, + modules: [], + includeComponents: ['skill-extra'], + }, + resolution: { + selectedModules: [], + skippedModules: [], + }, + operations: [extraOperation], + }, + }; + + applyInstallPlan(extraPlan); + const stateAfterExtraInstall = readInstallState(fixture.installStatePath); + assert.ok(fixture.operations.every(operation => ( + stateAfterExtraInstall.operations.some(recorded => ( + recorded.destinationPath === operation.destinationPath + )) + ))); + assert.ok(stateAfterExtraInstall.operations.some(operation => ( + operation.destinationPath === extraDestinationPath + ))); + + const retry = applyInstallPlan(fixture.plan); + assert.deepStrictEqual(retry.skippedOperations, []); + const stateAfterRetry = readInstallState(fixture.installStatePath); + assert.ok(stateAfterRetry.operations.some(operation => ( + operation.destinationPath === extraDestinationPath + ))); + + const uninstall = runUninstall(fixture); + assert.strictEqual(uninstall.summary.errorCount, 0); + assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath))); + assert.ok(!fs.existsSync(extraDestinationPath)); + } finally { + cleanup(fixture.tempDir); + } + })) passed++; else failed++; + if (test('tracks a partial migration so retry and uninstall remain safe', () => { const fixture = createFixture(); try {