From 285f1a789e56c73b758d25dd9014a1af0813e0e2 Mon Sep 17 00:00:00 2001 From: Patrick Obrtal <73367326+khons-hu@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:07:05 +0200 Subject: [PATCH] fix: reconcile stale managed install operations --- scripts/install-apply.js | 7 + scripts/lib/install/apply.js | 46 +- scripts/lib/install/claude-skill-migration.js | 5 + .../stale-operations-reconciliation.js | 288 +++++++++ tests/lib/install-stale-operations.test.js | 555 ++++++++++++++++++ 5 files changed, 894 insertions(+), 7 deletions(-) create mode 100644 scripts/lib/install/stale-operations-reconciliation.js create mode 100644 tests/lib/install-stale-operations.test.js diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 722f7d6b6..d32ccdfd3 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -139,6 +139,13 @@ function printHumanPlan(plan, dryRun) { } } + if (Array.isArray(plan.reconciledStalePaths) && plan.reconciledStalePaths.length > 0) { + console.log('\nRemoved files no longer in the install plan:'); + for (const removedPath of plan.reconciledStalePaths) { + console.log(`- removed ${removedPath}`); + } + } + if (!dryRun) { console.log(`\nDone. Install-state written to ${plan.installStatePath}`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 1776cb6ca..b845d8eac 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -38,6 +38,12 @@ const { completeExcludedPathsReconciliation, prepareExcludedPathsReconciliation, } = require('./excluded-paths-reconciliation'); +const { + completeStaleOperationsReconciliation, + describeStaleOperationsPreview, + prepareStaleOperationsReconciliation, + withoutStaleOperations, +} = require('./stale-operations-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { adaptAntigravityAgent } = require('./antigravity-agent'); @@ -400,9 +406,12 @@ function prepareHookConsentMigration(plan, migration) { } function previewInstallPlan(plan) { - const migration = prepareHookConsentMigration( + const migration = prepareStaleOperationsReconciliation( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ) ); const appliedPlan = { ...plan, @@ -414,7 +423,7 @@ function previewInstallPlan(plan) { : []; return { ...plan, - statePreview: migration.finalState, + statePreview: withoutStaleOperations(migration.finalState, migration), plannedOperations: [...plan.operations], operations: migration.appliedOperations, skippedOperations: migration.skippedOperations, @@ -422,6 +431,7 @@ function previewInstallPlan(plan) { ...(Array.isArray(plan.warnings) ? plan.warnings : []), ...migration.warnings, ...hookConsentWarnings, + ...describeStaleOperationsPreview(migration), ], applied: false, }; @@ -453,11 +463,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } - const migration = prepareExcludedPathsReconciliation( + const migration = prepareStaleOperationsReconciliation( plan, - prepareHookConsentMigration( + prepareExcludedPathsReconciliation( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ) ) ); const appliedPlan = { @@ -605,7 +618,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ); } - finalState = stateWithContentDigests(migration.finalState, appliedPlan); + // Stale records leave only the successful final state; the bridge and + // failure checkpoint above keep them so a failed install retains ownership. + finalState = stateWithContentDigests( + withoutStaleOperations(migration.finalState, migration), + appliedPlan + ); if (typeof beforeInstallStateWrite === 'function') { beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); } @@ -685,6 +703,18 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ]; } + let stalePathsRemoved = []; + let stalePathsWarnings = []; + try { + const staleReconciliation = completeStaleOperationsReconciliation(migration, appliedPlan); + stalePathsRemoved = staleReconciliation.removedPaths; + stalePathsWarnings = staleReconciliation.warnings; + } catch (error) { + stalePathsWarnings = [ + `Stale install-state reconciliation did not finish: ${error.message}. Files ECC no longer installs were preserved; remove them manually if unwanted.`, + ]; + } + return { ...plan, statePreview: finalState, @@ -692,12 +722,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals operations: migration.appliedOperations, skippedOperations: migration.skippedOperations, reconciledExcludedPaths: excludedPathsRemoved, + reconciledStalePaths: stalePathsRemoved, warnings: [ ...(Array.isArray(plan.warnings) ? plan.warnings : []), ...migration.warnings, ...antigravityMigrationWarnings, ...opencodeMigrationWarnings, ...excludedPathsWarnings, + ...stalePathsWarnings, ], applied: true, }; diff --git a/scripts/lib/install/claude-skill-migration.js b/scripts/lib/install/claude-skill-migration.js index adc9170b3..4bf396073 100644 --- a/scripts/lib/install/claude-skill-migration.js +++ b/scripts/lib/install/claude-skill-migration.js @@ -243,6 +243,7 @@ function createDisabledMigration(plan, previousState) { bridgeState: finalState, finalState, legacyOperationsToRemove: [], + retainedLegacyOperations: [], requiresBridgeState: plan.operations.length > 0, }; } @@ -380,6 +381,10 @@ function prepareClaudeSkillMigration(plan) { bridgeState: states.bridgeState, finalState: states.finalState, legacyOperationsToRemove: states.legacyOperationsToRemove, + // Nested legacy copies this migration deliberately keeps (user-owned flat + // conflicts, or skills this plan does not install). Stale-operation + // reconciliation must not treat them as obsolete. + retainedLegacyOperations: [...classification.retainedLegacyOperations], requiresBridgeState: states.requiresBridgeState, }; } diff --git a/scripts/lib/install/stale-operations-reconciliation.js b/scripts/lib/install/stale-operations-reconciliation.js new file mode 100644 index 000000000..a38953d99 --- /dev/null +++ b/scripts/lib/install/stale-operations-reconciliation.js @@ -0,0 +1,288 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { assertWithinTrustedRoot } = require('../path-safety'); +const { isHookRuntimeOperation } = require('./hook-consent'); + +/** + * Stale managed copy-file reconciliation (issue #3232). + * + * Install-state is cumulative: every reinstall merges the previous managed + * operations with the new plan so selective `--modules` installs keep earlier + * ownership. Without reconciliation, a copy-file operation that a selected + * module no longer ships (a removed source, or a file whose kind/destination + * changed) stays in install-state forever and doctor reports it as missing or + * drifted on every run. + * + * prepareStaleOperationsReconciliation identifies those records: managed + * copy-file operations whose module is selected by the current plan but whose + * destination is no longer planned by any operation. The records stay in the + * bridge/checkpoint states so a failed install keeps its ownership; + * withoutStaleOperations drops them only from the successful final state and + * the dry-run preview. completeStaleOperationsReconciliation runs after the + * final state is written and deletes a destination only when it is an + * unchanged regular file with a recorded digest, reached without symlinks, + * inside the target root. Anything else is preserved with one orphan warning. + */ + +const SHA256_PATTERN = /^[a-f0-9]{64}$/i; + +function comparablePath(filePath) { + const resolvedPath = path.resolve(filePath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function destinationKeys(operations) { + return new Set((operations || []) + .filter(operation => operation && typeof operation.destinationPath === 'string') + .map(operation => comparablePath(operation.destinationPath))); +} + +function materializesWholeModules(plan) { + // Legacy language installs are cumulative inside one module: pure legacy + // plans share a module id across languages and Antigravity legacy-compat + // filters rules-core by language, so an absent operation is not stale there. + if (plan.mode === 'legacy') { + return false; + } + const request = plan.statePreview && plan.statePreview.request; + return !(request && request.legacyMode && plan.target === 'antigravity'); +} + +function hooksDeclined(plan) { + const request = plan.statePreview && plan.statePreview.request; + return plan.hookConsent === 'declined' || Boolean(request && request.hookConsent === 'declined'); +} + +function prepareStaleOperationsReconciliation(plan, migration) { + const unchanged = { ...migration, staleOperationCandidates: [] }; + const finalOperations = migration && migration.finalState && migration.finalState.operations; + const resolution = plan && plan.statePreview && plan.statePreview.resolution; + if (!Array.isArray(finalOperations) || !resolution || !materializesWholeModules(plan)) { + return unchanged; + } + + const selectedModules = new Set(resolution.selectedModules || []); + const plannedDestinations = destinationKeys([ + ...(plan.operations || []), + ...(plan.statePreview.operations || []), + ]); + // Claude flat-skill migration deliberately keeps nested legacy copies that + // conflict with user-owned flat skills; those records are not stale. + const retainedLegacyDestinations = destinationKeys(migration.retainedLegacyOperations); + const skipHookRuntime = hooksDeclined(plan); + const seen = new Set(); + const staleOperationCandidates = finalOperations.filter(operation => { + if ( + !operation + || operation.kind !== 'copy-file' + || operation.ownership !== 'managed' + || typeof operation.destinationPath !== 'string' + || !selectedModules.has(operation.moduleId) + // Declining hooks strips hook files without the module dropping them. + || (skipHookRuntime && isHookRuntimeOperation(operation)) + ) { + return false; + } + const key = comparablePath(operation.destinationPath); + if (plannedDestinations.has(key) || retainedLegacyDestinations.has(key) || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + return { ...migration, staleOperationCandidates }; +} + +function withoutStaleOperations(state, migration) { + const staleDestinations = destinationKeys(migration && migration.staleOperationCandidates); + if (!state || !Array.isArray(state.operations) || staleDestinations.size === 0) { + return state; + } + return { + ...state, + operations: state.operations.filter(operation => !( + operation + && operation.kind === 'copy-file' + && typeof operation.destinationPath === 'string' + && staleDestinations.has(comparablePath(operation.destinationPath)) + )), + }; +} + +function describeStaleOperationsPreview(migration) { + const count = ((migration && migration.staleOperationCandidates) || []).length; + if (count === 0) { + return []; + } + return [ + `${count} previously managed file(s) are no longer part of this install plan; ` + + 'applying it removes their install-state records and deletes only unchanged copies.', + ]; +} + +class PreservedStaleFile extends Error {} + +function lstatOrNull(filePath) { + try { + return fs.lstatSync(filePath, { bigint: true }); + } catch (error) { + if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { + return null; + } + throw error; + } +} + +// Returns the destination's lstat, or null when it is absent. Every segment +// from the root to the destination is checked with lstat so an in-root +// symlink can never redirect the delete onto its target. +function inspectDestinationPath(destinationPath, targetRoot) { + const resolvedRoot = path.resolve(targetRoot); + const resolvedTarget = path.resolve(destinationPath); + const relativePath = path.relative(resolvedRoot, resolvedTarget); + if ( + !relativePath + || relativePath === '..' + || relativePath.startsWith(`..${path.sep}`) + || path.isAbsolute(relativePath) + ) { + throw new PreservedStaleFile('it is outside the install root'); + } + assertWithinTrustedRoot(resolvedTarget, resolvedRoot, 'reconcile stale install file'); + + const segments = relativePath.split(path.sep); + let currentPath = resolvedRoot; + for (let index = 0; index <= segments.length; index += 1) { + if (index > 0) { + currentPath = path.join(currentPath, segments[index - 1]); + } + const stat = lstatOrNull(currentPath); + if (!stat) { + return null; + } + if (stat.isSymbolicLink()) { + throw new PreservedStaleFile(index === segments.length + ? 'it is a symlink' + : `its path passes through the symlink ${currentPath}`); + } + if (index < segments.length && !stat.isDirectory()) { + throw new PreservedStaleFile(`${currentPath} is not a directory`); + } + if (index === segments.length && !stat.isFile()) { + throw new PreservedStaleFile('it is not a regular file'); + } + if (index === segments.length) { + return stat; + } + } + return null; +} + +function hashFileNoFollow(filePath) { + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const descriptor = fs.openSync(filePath, flags); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile()) { + throw new PreservedStaleFile('it is not a regular file'); + } + const content = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size) { + throw new PreservedStaleFile('it changed while being verified'); + } + return { + digest: crypto.createHash('sha256').update(content).digest('hex'), + stat: after, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function sameFile(left, right) { + return Boolean(left && right) + && left.ino === right.ino + && left.dev === right.dev + && left.size === right.size; +} + +function removeEmptyParents(filePath, targetRoot) { + const resolvedRoot = path.resolve(targetRoot); + let currentPath = path.dirname(path.resolve(filePath)); + while ( + comparablePath(currentPath) !== comparablePath(resolvedRoot) + && comparablePath(currentPath).startsWith(`${comparablePath(resolvedRoot)}${path.sep}`) + ) { + const stat = lstatOrNull(currentPath); + if (!stat || stat.isSymbolicLink() || !stat.isDirectory() || fs.readdirSync(currentPath).length > 0) { + return; + } + fs.rmdirSync(currentPath); + currentPath = path.dirname(currentPath); + } +} + +// Removes one stale destination. Returns true when removed, false when it was +// already absent, and throws PreservedStaleFile when it must be kept. +function removeUnchangedStaleFile(candidate, targetRoot) { + const initialStat = inspectDestinationPath(candidate.destinationPath, targetRoot); + if (!initialStat) { + return false; + } + if (typeof candidate.contentSha256 !== 'string' || !SHA256_PATTERN.test(candidate.contentSha256)) { + throw new PreservedStaleFile('its record has no content digest, so it cannot be verified unchanged'); + } + + const destinationPath = path.resolve(candidate.destinationPath); + const hashed = hashFileNoFollow(destinationPath); + if (hashed.digest !== candidate.contentSha256.toLowerCase()) { + throw new PreservedStaleFile('its content changed after install'); + } + // Revalidate right before unlinking; the path must still name the exact + // regular file that was hashed. + const finalStat = inspectDestinationPath(destinationPath, targetRoot); + if (!sameFile(initialStat, hashed.stat) || !sameFile(finalStat, hashed.stat)) { + throw new PreservedStaleFile('it changed while being verified'); + } + fs.unlinkSync(destinationPath); + try { + removeEmptyParents(destinationPath, targetRoot); + } catch (_error) { + // Pruning emptied directories is best effort; the stale file is gone. + } + return true; +} + +function completeStaleOperationsReconciliation(migration, plan) { + const removedPaths = []; + const warnings = []; + for (const candidate of (migration && migration.staleOperationCandidates) || []) { + try { + if (removeUnchangedStaleFile(candidate, plan.targetRoot)) { + removedPaths.push(path.resolve(candidate.destinationPath)); + } + } catch (error) { + const reason = error instanceof PreservedStaleFile + ? error.message + : `it could not be verified or removed (${error.message})`; + warnings.push( + `Preserved orphaned file ${candidate.destinationPath}: ECC no longer installs it and ` + + `stopped tracking it, but ${reason}; remove it manually if unwanted.` + ); + } + } + return { removedPaths, warnings }; +} + +module.exports = { + completeStaleOperationsReconciliation, + describeStaleOperationsPreview, + prepareStaleOperationsReconciliation, + withoutStaleOperations, +}; diff --git a/tests/lib/install-stale-operations.test.js b/tests/lib/install-stale-operations.test.js new file mode 100644 index 000000000..07c50ada1 --- /dev/null +++ b/tests/lib/install-stale-operations.test.js @@ -0,0 +1,555 @@ +'use strict'; + +/** + * Regression tests for stale install-state operations (issue #3232). + * + * A reinstall must drop managed copy-file records that a selected module no + * longer ships, delete only unchanged regular files reached without symlinks, + * and keep ownership whenever the install fails or the record is out of scope. + */ + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply'); +const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); +const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); +const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request'); +const { buildDoctorReport } = require('../../scripts/lib/install-lifecycle'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +let passed = 0; +let failed = 0; +let skipped = 0; + +function test(name, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stale-ops-')); + try { + if (fn(root) === 'skip') { + console.log(` - ${name} (skipped: symlinks unavailable)`); + skipped += 1; + return; + } + console.log(` ✓ ${name}`); + passed += 1; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.stack || error.message}`); + failed += 1; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function trySymlink(targetPath, linkPath, type) { + try { + fs.symlinkSync(targetPath, linkPath, type); + return true; + } catch (error) { + if (process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES')) { + return false; + } + throw error; + } +} + +function cursorTarget(root) { + const targetRoot = path.join(root, '.cursor'); + return { + root, + targetRoot, + installStatePath: path.join(targetRoot, 'ecc-install-state.json'), + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + }; +} + +function claudeTarget(root) { + const targetRoot = path.join(root, '.claude'); + return { + root, + targetRoot, + installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'), + adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, + }; +} + +// spec: { moduleId, source, dest?, kind?, content?, payload?, skipSource? } +function buildOperation(target, spec) { + const destinationPath = path.join(target.targetRoot, spec.dest || spec.source); + if (spec.kind === 'merge-json') { + return { + kind: 'merge-json', + moduleId: spec.moduleId, + sourceRelativePath: spec.source, + destinationPath, + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: spec.payload || { managed: true }, + }; + } + const sourcePath = path.join(target.root, 'source', spec.source); + if (!spec.skipSource) { + writeFile(sourcePath, spec.content === undefined ? `${spec.source}\n` : spec.content); + } + return { + kind: 'copy-file', + moduleId: spec.moduleId, + sourcePath, + sourceRelativePath: spec.source, + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + }; +} + +function makePlan(target, specs, options = {}) { + const operations = specs.map(spec => buildOperation(target, spec)); + const modules = options.modules || [...new Set(operations.map(operation => operation.moduleId))]; + return { + mode: 'manifest', + target: target.adapter.target, + adapter: target.adapter, + targetRoot: target.targetRoot, + installRoot: target.targetRoot, + installStatePath: target.installStatePath, + operations, + statePreview: { + schemaVersion: 'ecc.install.v1', + installedAt: new Date().toISOString(), + target: { ...target.adapter, root: target.targetRoot, installStatePath: target.installStatePath }, + request: { + profile: null, + modules, + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + hookConsent: null, + }, + resolution: { selectedModules: modules, skippedModules: [] }, + source: { manifestVersion: 1 }, + operations, + }, + warnings: [], + }; +} + +function relativeDestinations(target, operations) { + const ops = operations || readInstallState(target.installStatePath).operations; + return ops + .map(operation => path.relative(target.targetRoot, operation.destinationPath).split(path.sep).join('/')) + .sort(); +} + +function findRecord(target, relativePath) { + const destinationPath = path.join(target.targetRoot, relativePath); + return readInstallState(target.installStatePath).operations + .find(operation => operation.destinationPath === destinationPath); +} + +function editState(target, transform) { + const state = readInstallState(target.installStatePath); + writeInstallState(target.installStatePath, { ...state, operations: transform(state.operations) }); +} + +function orphanWarnings(result) { + return result.warnings.filter(warning => warning.startsWith('Preserved orphaned file ')); +} + +const KEEP = { moduleId: 'core', source: 'lib/keep.js' }; + +console.log('\n=== Testing stale install-state operations (#3232) ===\n'); + +test('removes an unchanged stale copy and its record after a successful reinstall', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/obsolete/cost-estimate.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const stalePath = path.join(target.targetRoot, stale.source); + assert.ok(fs.existsSync(stalePath)); + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.ok(!fs.existsSync(stalePath), 'unchanged stale copy should be deleted'); + assert.ok(!fs.existsSync(path.dirname(stalePath)), 'emptied stale directory should be pruned'); + assert.ok(fs.existsSync(path.join(target.targetRoot, KEEP.source))); + assert.deepStrictEqual(result.reconciledStalePaths, [path.resolve(stalePath)]); + assert.deepStrictEqual(result.warnings, []); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); + assert.deepStrictEqual(relativeDestinations(target, result.statePreview.operations), ['lib/keep.js']); +}); + +test('preserves a modified stale copy, drops its record, and warns only once', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/edited.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const stalePath = path.join(target.targetRoot, stale.source); + fs.writeFileSync(stalePath, 'user edit\n'); + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n'); + assert.deepStrictEqual(result.reconciledStalePaths, []); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1, warnings.join('\n')); + assert.ok(warnings[0].includes(stalePath) && /content changed after install/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); + + const rerun = applyInstallPlan(makePlan(target, [KEEP])); + assert.deepStrictEqual(orphanWarnings(rerun), [], 'an untracked orphan must not warn again'); + assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n'); +}); + +test('preserves a stale copy whose record has no content digest', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/old-ledger.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + editState(target, operations => operations.map(operation => { + if (!operation.destinationPath.endsWith('old-ledger.js')) { + return operation; + } + const { contentSha256: _digest, ...digestless } = operation; + return digestless; + })); + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source))); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1); + assert.ok(/no content digest/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('preserves a stale destination that is no longer a regular file', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/now-a-dir.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const stalePath = path.join(target.targetRoot, stale.source); + fs.rmSync(stalePath); + writeFile(path.join(stalePath, 'user.txt'), 'user data\n'); + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.strictEqual(fs.readFileSync(path.join(stalePath, 'user.txt'), 'utf8'), 'user data\n'); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1); + assert.ok(/not a regular file/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('never deletes a stale record destination outside the target root', root => { + const target = cursorTarget(root); + applyInstallPlan(makePlan(target, [KEEP])); + const outsidePath = path.join(root, 'outside', 'victim.js'); + writeFile(outsidePath, 'victim\n'); + editState(target, operations => [...operations, { + kind: 'copy-file', + moduleId: 'core', + sourceRelativePath: 'lib/victim.js', + destinationPath: outsidePath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: sha256('victim\n'), + }]); + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), 'victim\n'); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1); + assert.ok(/outside the install root/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('never deletes the in-root target of a stale symlinked destination', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/linked.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const linkPath = path.join(target.targetRoot, stale.source); + // The link target holds exactly the recorded bytes, so following the link + // would pass the digest check and delete the user's file. + const linkTarget = path.join(target.targetRoot, 'user', 'linked-target.js'); + writeFile(linkTarget, `${stale.source}\n`); + fs.rmSync(linkPath); + if (!trySymlink(linkTarget, linkPath, 'file')) { + return 'skip'; + } + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'the symlink itself must be preserved'); + assert.strictEqual(fs.readFileSync(linkTarget, 'utf8'), `${stale.source}\n`); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1); + assert.ok(/is a symlink/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('never deletes through an in-root symlinked parent directory', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'nested/stale.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const linkedDir = path.join(target.targetRoot, 'nested'); + const realDir = path.join(target.targetRoot, 'real-nested'); + fs.renameSync(linkedDir, realDir); + if (!trySymlink(realDir, linkedDir, 'dir')) { + return 'skip'; + } + + const result = applyInstallPlan(makePlan(target, [KEEP])); + assert.strictEqual(fs.readFileSync(path.join(realDir, 'stale.js'), 'utf8'), `${stale.source}\n`); + assert.ok(fs.lstatSync(linkedDir).isSymbolicLink()); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1); + assert.ok(/passes through the symlink/.test(warnings[0])); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('dry-run previews the reconciled state without mutating files or install-state', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/obsolete.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const stateBefore = fs.readFileSync(target.installStatePath, 'utf8'); + + const preview = previewInstallPlan(makePlan(target, [KEEP])); + assert.strictEqual(preview.applied, false); + assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source)), 'dry-run must not delete'); + assert.strictEqual(fs.readFileSync(target.installStatePath, 'utf8'), stateBefore); + assert.deepStrictEqual(relativeDestinations(target, preview.statePreview.operations), ['lib/keep.js']); + assert.ok(preview.warnings.some(warning => /^1 previously managed file\(s\) are no longer part/.test(warning))); + assert.deepStrictEqual(orphanWarnings(preview), []); +}); + +test('a failed reinstall keeps stale records and files for a later retry', root => { + const target = cursorTarget(root); + const stale = { moduleId: 'core', source: 'lib/obsolete.js' }; + applyInstallPlan(makePlan(target, [KEEP, stale])); + const stalePath = path.join(target.targetRoot, stale.source); + const recorded = findRecord(target, stale.source); + + // Failure while copying: the checkpoint is the bridge state. + const broken = { moduleId: 'core', source: 'lib/missing-source.js', skipSource: true }; + assert.throws(() => applyInstallPlan(makePlan(target, [KEEP, broken])), /ENOENT/); + assert.ok(fs.existsSync(stalePath)); + assert.deepStrictEqual(findRecord(target, stale.source), recorded); + + // Failure while persisting the final state, after every copy succeeded. + let stateWrites = 0; + assert.throws(() => applyInstallPlan(makePlan(target, [KEEP]), { + beforeInstallStateWrite() { + stateWrites += 1; + if (stateWrites === 2) { + throw new Error('simulated final install-state write failure'); + } + }, + }), /simulated final install-state write failure/); + assert.strictEqual(stateWrites, 2, 'bridge and final state writes should both be attempted'); + assert.ok(fs.existsSync(stalePath), 'stale file must survive a failed install'); + assert.deepStrictEqual(findRecord(target, stale.source), recorded); + + const retry = applyInstallPlan(makePlan(target, [KEEP])); + assert.ok(!fs.existsSync(stalePath)); + assert.deepStrictEqual(retry.reconciledStalePaths, [path.resolve(stalePath)]); + assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']); +}); + +test('selective --modules installs only reconcile modules they select', root => { + const target = cursorTarget(root); + const alphaOne = { moduleId: 'alpha', source: 'alpha/one.md' }; + const alphaTwo = { moduleId: 'alpha', source: 'alpha/two.md' }; + const beta = { moduleId: 'beta', source: 'beta/one.md' }; + applyInstallPlan(makePlan(target, [alphaOne, alphaTwo])); + applyInstallPlan(makePlan(target, [beta])); + assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'alpha/two.md', 'beta/one.md']); + + const alphaResult = applyInstallPlan(makePlan(target, [alphaOne])); + assert.ok(!fs.existsSync(path.join(target.targetRoot, alphaTwo.source))); + assert.deepStrictEqual(alphaResult.reconciledStalePaths, [ + path.resolve(path.join(target.targetRoot, alphaTwo.source)), + ]); + assert.ok(fs.existsSync(path.join(target.targetRoot, beta.source)), 'unselected module files stay'); + assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']); + + const betaResult = applyInstallPlan(makePlan(target, [beta])); + assert.deepStrictEqual(betaResult.reconciledStalePaths, []); + assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']); +}); + +test('reconciles a destination change for the same module and source', root => { + const target = cursorTarget(root); + applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'old/tool.json' }])); + const result = applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'new/tool.json' }])); + assert.ok(!fs.existsSync(path.join(target.targetRoot, 'old', 'tool.json'))); + assert.ok(fs.existsSync(path.join(target.targetRoot, 'new', 'tool.json'))); + assert.strictEqual(result.reconciledStalePaths.length, 1); + const state = readInstallState(target.installStatePath); + assert.deepStrictEqual(relativeDestinations(target), ['new/tool.json']); + assert.strictEqual(state.operations[0].sourceRelativePath, 'cfg/tool.json'); +}); + +test('keeps a destination whose kind changes and drops a kind-plus-destination move', root => { + const target = cursorTarget(root); + applyInstallPlan(makePlan(target, [ + { moduleId: 'core', source: 'cfg/config.json', dest: 'config.json', content: '{"base":1}\n' }, + { moduleId: 'core', source: 'cfg/events.json', dest: 'events/events.json', content: '{"events":[]}\n' }, + ])); + + const result = applyInstallPlan(makePlan(target, [ + { moduleId: 'core', kind: 'merge-json', source: 'cfg/config.json', dest: 'config.json', payload: { merged: true } }, + { moduleId: 'core', kind: 'merge-json', source: 'cfg/events.json', dest: 'settings.json', payload: { events: [] } }, + ])); + + assert.deepStrictEqual( + JSON.parse(fs.readFileSync(path.join(target.targetRoot, 'config.json'), 'utf8')), + { base: 1, merged: true }, + 'a same-destination kind change must keep the file' + ); + assert.ok(!fs.existsSync(path.join(target.targetRoot, 'events', 'events.json'))); + assert.deepStrictEqual(result.reconciledStalePaths, [ + path.resolve(path.join(target.targetRoot, 'events', 'events.json')), + ]); + const state = readInstallState(target.installStatePath); + assert.deepStrictEqual(relativeDestinations(target), ['config.json', 'settings.json']); + assert.ok(state.operations.every(operation => operation.kind === 'merge-json')); +}); + +test('preserves Claude legacy-skill migration conflict ownership', root => { + const target = claudeTarget(root); + const legacyPath = path.join(target.targetRoot, 'skills', 'ecc', 'demo', 'SKILL.md'); + const flatUserPath = path.join(target.targetRoot, 'skills', 'demo', 'SKILL.md'); + writeFile(legacyPath, '# demo (nested ECC copy)\n'); + writeFile(flatUserPath, '# demo (user-owned)\n'); + const seedPlan = makePlan(target, []); + writeInstallState(target.installStatePath, { + ...seedPlan.statePreview, + request: { ...seedPlan.statePreview.request, modules: ['skills-core'] }, + resolution: { selectedModules: ['skills-core'], skippedModules: [] }, + operations: [{ + kind: 'copy-file', + moduleId: 'skills-core', + sourceRelativePath: 'skills/demo/SKILL.md', + destinationPath: legacyPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: sha256('# demo (nested ECC copy)\n'), + }], + }); + + const result = applyInstallPlan(makePlan(target, [ + { moduleId: 'skills-core', source: 'skills/demo/SKILL.md', dest: 'skills/demo/SKILL.md' }, + { moduleId: 'skills-core', source: 'rules/keep.md', dest: 'rules/ecc/keep.md' }, + ])); + + assert.ok(result.warnings.some(warning => warning.startsWith("Skipped Claude skill 'demo'"))); + assert.deepStrictEqual(orphanWarnings(result), []); + assert.deepStrictEqual(result.reconciledStalePaths, []); + assert.strictEqual(fs.readFileSync(legacyPath, 'utf8'), '# demo (nested ECC copy)\n'); + assert.strictEqual(fs.readFileSync(flatUserPath, 'utf8'), '# demo (user-owned)\n'); + assert.deepStrictEqual(relativeDestinations(target), ['rules/ecc/keep.md', 'skills/ecc/demo/SKILL.md']); +}); + +test('declining hooks keeps hook files from modules that remain selected', root => { + const target = cursorTarget(root); + const hook = { moduleId: 'platform-configs', source: '.cursor/hooks/after-edit.js', dest: 'hooks/after-edit.js' }; + const rule = { moduleId: 'platform-configs', source: 'rules/keep.md' }; + applyInstallPlan(withHookConsent(makePlan(target, [hook, rule]), 'enabled')); + + const declined = withHookConsent(makePlan(target, [hook, rule]), 'declined'); + assert.ok(!declined.operations.some(operation => operation.sourceRelativePath === hook.source)); + const result = applyInstallPlan(declined); + assert.deepStrictEqual(result.reconciledStalePaths, []); + assert.ok(fs.existsSync(path.join(target.targetRoot, hook.dest))); + assert.deepStrictEqual(relativeDestinations(target), ['hooks/after-edit.js', 'rules/keep.md']); +}); + +test('legacy language installs stay cumulative inside a shared module', root => { + const target = cursorTarget(root); + const legacyPlan = specs => ({ ...makePlan(target, specs), mode: 'legacy' }); + applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/python-style.md' }])); + const result = applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/typescript-style.md' }])); + assert.deepStrictEqual(result.reconciledStalePaths, []); + assert.ok(fs.existsSync(path.join(target.targetRoot, 'rules', 'python-style.md'))); + assert.deepStrictEqual(relativeDestinations(target), ['rules/python-style.md', 'rules/typescript-style.md']); +}); + +test('Claude home reinstall drops obsolete hooks.json and cost-estimate.js records (#3232)', root => { + const homeDir = path.join(root, 'home'); + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + const env = { HOME: homeDir, USERPROFILE: homeDir }; + const request = normalizeInstallRequest({ + ...parseInstallArgs(['node', 'install-apply.js', '--profile', 'core', '--enable-hooks']), + config: null, + }); + const createPlan = () => createInstallPlanFromRequest(request, { projectRoot, homeDir, env }); + const doctorCodes = () => buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['claude'], env }) + .results.flatMap(result => result.issues.map(issue => issue.code)); + + const initialPlan = createPlan(); + applyInstallPlan(initialPlan); + const claudeRoot = initialPlan.targetRoot; + const hooksJsonPath = path.join(claudeRoot, 'hooks', 'hooks.json'); + const costEstimatePath = path.join(claudeRoot, 'scripts', 'lib', 'cost-estimate.js'); + const legacyHooksJson = '{"hooks":{}}\n'; + const legacyCostEstimate = 'module.exports = {};\n'; + // Seed the records an older pin left behind: hooks.json was copied before it + // moved into settings.json, and cost-estimate.js was later deleted upstream. + writeFile(hooksJsonPath, legacyHooksJson); + writeFile(costEstimatePath, '// user tweak\n'); + const staleRecord = (sourceRelativePath, destinationPath, installedContent) => ({ + kind: 'copy-file', + moduleId: 'hooks-runtime', + sourcePath: path.join(REPO_ROOT, sourceRelativePath), + sourceRelativePath, + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: sha256(installedContent), + }); + editState({ installStatePath: initialPlan.installStatePath }, operations => [ + ...operations, + staleRecord('hooks/hooks.json', hooksJsonPath, legacyHooksJson), + staleRecord('scripts/lib/cost-estimate.js', costEstimatePath, legacyCostEstimate), + ]); + assert.ok(doctorCodes().includes('missing-source-files'), 'seeded state should reproduce the doctor error'); + + const plannedDestinations = new Set(createPlan().operations.map(operation => operation.destinationPath)); + const preview = previewInstallPlan(createPlan()); + assert.ok( + preview.statePreview.operations.every(operation => plannedDestinations.has(operation.destinationPath)), + 'dry-run state preview must not carry operations the plan no longer contains' + ); + assert.ok(fs.existsSync(hooksJsonPath) && fs.existsSync(costEstimatePath)); + + const result = applyInstallPlan(createPlan()); + assert.ok(!fs.existsSync(hooksJsonPath), 'unchanged obsolete hooks.json should be removed'); + assert.strictEqual(fs.readFileSync(costEstimatePath, 'utf8'), '// user tweak\n'); + const warnings = orphanWarnings(result); + assert.strictEqual(warnings.length, 1, warnings.join('\n')); + assert.ok(warnings[0].includes(costEstimatePath)); + + const state = readInstallState(initialPlan.installStatePath); + assert.ok(!state.operations.some(operation => ( + operation.destinationPath === hooksJsonPath || operation.destinationPath === costEstimatePath + ))); + const codes = doctorCodes(); + assert.ok(!codes.includes('missing-source-files'), `doctor still reports: ${codes.join(', ')}`); + assert.ok(!codes.includes('drifted-managed-files'), `doctor still reports: ${codes.join(', ')}`); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}, Skipped: ${skipped}`); +process.exit(failed > 0 ? 1 : 0);