Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Sources/mcs/Core/ProjectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ struct ProjectState {
storage.resolvedValues = values
}

/// Remove resolved-value entries whose keys are not in the provided set.
mutating func pruneResolvedValues(keepingKeys keys: Set<String>) {
guard let current = storage.resolvedValues, !current.isEmpty else { return }
let kept = current.filter { keys.contains($0.key) }
storage.resolvedValues = kept.isEmpty ? nil : kept
}

// MARK: - Persistence

/// Save to disk. Updates internal state with timestamp and version.
Expand Down
27 changes: 27 additions & 0 deletions Sources/mcs/Sync/Configurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ struct Configurator {
guard let artifacts = state.artifacts(for: packID) else {
output.dimmed("No artifact record for \(packID) — skipping")
state.removePack(packID)
pruneOrphanResolvedValues(state: &state)
return
}

Expand Down Expand Up @@ -489,6 +490,32 @@ struct Configurator {
state.setArtifacts(remaining, for: packID)
output.warn("Some artifacts for \(packID) could not be removed. Re-run '\(scope.syncHint)' to retry.")
}
pruneOrphanResolvedValues(state: &state)
}

/// Drop `state.resolvedValues` entries whose keys are not declared by any currently-configured pack.
/// Invoked at the tail of `unconfigurePack` so both `mcs sync` deselection and `mcs pack remove`
/// federated cleanup prune orphans — a later pack declaring the same key is asked fresh instead
/// of seeing a stale "prior" from a removed pack.
private func pruneOrphanResolvedValues(state: inout ProjectState) {
var survivingPacks: [any TechPack] = []
for packID in state.configuredPacks {
guard let pack = registry.pack(for: packID) else {
// Conservative fallback matching ResourceRefCounter: if any configured pack
// can't be loaded from the registry, we can't enumerate its declared keys,
// so skip pruning rather than risk dropping values that still belong.
return
}
survivingPacks.append(pack)
}
let priors = state.resolvedValues ?? [:]
let context = strategy.makeConfigContext(
output: output, resolvedValues: priors, priorValues: priors
)
let declared = CrossPackPromptResolver.collectDeclaredPrompts(
packs: survivingPacks, context: context
)
state.pruneResolvedValues(keepingKeys: Set(declared.lazy.map(\.key)))
}

/// Remove artifacts for components that were previously included but are now excluded.
Expand Down
113 changes: 113 additions & 0 deletions Tests/MCSTests/LifecycleIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,119 @@ struct PromptValueReuseLifecycleTests {
#expect(final.resolvedValues?["KEY_A"] != "SHOULD_NOT_APPEAR")
}

@Test("Removing a pack prunes its resolvedValues; a later pack with same key is asked fresh")
func removedPackOrphanPruned() throws {
let bed = try LifecycleTestBed()
defer { bed.cleanup() }

let packA = MockPromptTechPack(
identifier: "pack-a",
displayName: "Pack A",
prompts: [inputPrompt("BRANCH_PREFIX")],
defaultAnswer: { "a-\($0)" }
)
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packA]))
.configure(packs: [packA], confirmRemovals: false)

var state = try bed.projectState()
state.setResolvedValues(["BRANCH_PREFIX": "bruno"])
try state.save()

// Deselect pack A: registry still knows the pack (so unconfigure can resolve
// survivors) but configure() passes an empty selection → removal triggers prune.
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packA]))
.configure(packs: [], confirmRemovals: false)

// BRANCH_PREFIX should be pruned — no surviving pack declares it.
let afterRemoval = try bed.projectState()
#expect(afterRemoval.resolvedValues?["BRANCH_PREFIX"] == nil)

let packB = MockPromptTechPack(
identifier: "pack-b",
displayName: "Pack B",
prompts: [inputPrompt("BRANCH_PREFIX")],
defaultAnswer: { "b-\($0)" }
)
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packB]))
.configure(packs: [packB], confirmRemovals: false)

// Pack B sees no prior for BRANCH_PREFIX → mock falls back to its defaultAnswer,
// NOT the stale "bruno" from removed pack A.
let final = try bed.projectState()
#expect(final.resolvedValues?["BRANCH_PREFIX"] == "b-BRANCH_PREFIX")
}

@Test("Shared resolved key preserved when one of two declaring packs is removed")
func sharedKeyRetainedAfterPartialRemoval() throws {
let bed = try LifecycleTestBed()
defer { bed.cleanup() }

let packA = MockPromptTechPack(
identifier: "pack-a",
displayName: "Pack A",
prompts: [inputPrompt("BRANCH_PREFIX")],
defaultAnswer: { "a-\($0)" }
)
let packB = MockPromptTechPack(
identifier: "pack-b",
displayName: "Pack B",
prompts: [inputPrompt("BRANCH_PREFIX")],
defaultAnswer: { "b-\($0)" }
)
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packA, packB]))
.configure(packs: [packA, packB], confirmRemovals: false)

var state = try bed.projectState()
state.setResolvedValues(["BRANCH_PREFIX": "bruno"])
try state.save()

// Pack B still declares BRANCH_PREFIX, so the value must survive removal of A.
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packA, packB]))
.configure(packs: [packB], confirmRemovals: false)

let final = try bed.projectState()
#expect(final.resolvedValues?["BRANCH_PREFIX"] == "bruno")
}

@Test("Pruning skips when a configured survivor pack is missing from the registry")
func pruningSkippedWhenSurvivorUnresolvable() throws {
let bed = try LifecycleTestBed()
defer { bed.cleanup() }

let packA = MockPromptTechPack(
identifier: "pack-a",
displayName: "Pack A",
prompts: [inputPrompt("KEY_A")],
defaultAnswer: { "a-\($0)" }
)
let packB = MockPromptTechPack(
identifier: "pack-b",
displayName: "Pack B",
prompts: [inputPrompt("KEY_B")],
defaultAnswer: { "b-\($0)" }
)
try bed.makeConfigurator(registry: TechPackRegistry(packs: [packA, packB]))
.configure(packs: [packA, packB], confirmRemovals: false)

var state = try bed.projectState()
state.setResolvedValues(["KEY_A": "user-a", "KEY_B": "user-b"])
try state.save()

// Direct unconfigure with a registry that omits pack-a simulates pack-a's
// directory being manually removed from ~/.mcs/packs/ — pack-a stays in
// state.configuredPacks but can no longer be resolved. The prune helper
// must refuse to run rather than silently drop keys that still belong.
state = try bed.projectState()
let narrowConfigurator = bed.makeConfigurator(
registry: TechPackRegistry(packs: [packB])
)
narrowConfigurator.unconfigurePack("pack-b", state: &state)
try state.save()

let final = try bed.projectState()
#expect(final.resolvedValues?["KEY_A"] == "user-a")
}

@Test("--customize forces re-ask even when priors are available")
func customizeForceReAsk() throws {
let bed = try LifecycleTestBed()
Expand Down
Loading