ISSUE-371: Flag packs configured in both global and project scope - #377
Conversation
- Add a doctor check naming duplicated skills, hooks and CLAUDE.md sections, with --fix removing the project copy only when provably lossless - Share the file-drift and template-dependency rules so doctor and sync cannot disagree about what is installed - Add per-project pack removal to ProjectIndex, so removing one pack no longer stamps a sync that never happened Claude-Session: https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN
- List ScopeDuplicationCheck.swift alongside the other Doctor modules, noting the three gates that must pass before --fix removes anything Claude-Session: https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN
There was a problem hiding this comment.
🟡 Changes recommended
--pack parsing does not trim whitespace, so common inputs like --pack "ios, swift" can silently fail to match pack IDs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new mcs doctor check to detect packs that are configured in both global and project scope (where artifacts are truly duplicated), and introduces a guarded --fix path that removes only the project-scoped copy when it can prove the removal is lossless.
Changes:
- Introduces
ScopeDuplicationCheckto report per-pack duplication across scopes and (when safe) remove the project-scoped copy while keeping the global copy. - Centralizes “template dependencies vs excluded components” filtering via
excludingDependencies(on:)and reuses it in both sync and doctor. - Adds
FileHasher.driftto unify “content drift” semantics between checks and fix gating; extends project index utilities and adds integration/unit tests for the new behavior.
File summaries
| File | Description |
|---|---|
| Tests/MCSTests/ProjectIndexTests.swift | Adds unit tests for removing a pack from a single project entry without affecting others. |
| Tests/MCSTests/LifecycleIntegrationTests.swift | Adds integration coverage for scope duplication detection, fix gating (exclusions/prompts/drift), and fix behavior (including gitignore preservation). |
| Sources/mcs/TechPack/TechPack.swift | Adds excludingDependencies(on:) helper for consistent template filtering by excluded components. |
| Sources/mcs/Sync/Configurator.swift | Uses excludingDependencies(on:) during template preloading to match doctor’s section-duplication logic. |
| Sources/mcs/Doctor/ScopeDuplicationCheck.swift | New doctor check to detect duplicated artifacts across scopes and a guarded fix that unconfigures the project-scoped pack copy. |
| Sources/mcs/Doctor/DoctorRunner.swift | Wires scope-duplication checks into project doctor runs and centralizes --pack parsing into packFilterIDs. |
| Sources/mcs/Doctor/CoreDoctorChecks.swift | Updates --fix responsibility boundary docs and refactors FileContentCheck to use FileHasher.drift. |
| Sources/mcs/Core/ProjectIndex.swift | Adds removePack(_:fromProject:in:) to remove one pack from one project without stamping lastSynced. |
| Sources/mcs/Core/FileHasher.swift | Adds DriftState + drift(of:expecting:) for consistent drift classification across checks and fix gating. |
| CLAUDE.md | Documents the new ScopeDuplicationCheck behavior and its fix gates. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// `packFilter` split into identifiers, so the comma convention is defined in one place. | ||
| private var packFilterIDs: Set<String>? { | ||
| packFilter.map { Set($0.components(separatedBy: ",")) } | ||
| } |
- Resolve the LifecycleIntegrationTests conflict by keeping both appended suites: HookInterpreterLifecycleTests from #368 and ScopeDuplicationCheckTests - Reword the duplication rationale for the interpreter change: only the hook directory is scope-dependent, so the two entries are still distinct Claude-Session: https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN
- '--pack "ios, swift"' previously produced a " swift" id that matched no pack and was reported as unregistered instead of being checked - Add a doctor integration test asserting the two spellings of one filter produce identical runs Claude-Session: https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN
|
Merged The On the merge: the only conflict was two test suites appended to the same file, resolved by keeping both. #368 changes hook commands to Also filed the follow-ups noted in the description: #378 (gitignore entries are not reference-counted — the one with user-visible consequences today), #379, #380. |
Summary
Installing the same pack globally and in a project leaves two live copies of everything it ships: the hook is registered under two different command strings so it fires twice, skills and commands sit in two directories, and the CLAUDE.md section is composed into both files. #370 stopped new duplicates from being created but deliberately left existing ones alone, so until now nothing found them. Closes #371.
Changes
mcs doctor, run inside a project, reports each pack present in both scopes and names what is actually duplicated — a pack whose two scopes were customized differently, or that only contributes brew packages, plugins or MCP servers, is not flagged.--fixremoves the project-scoped copy and keeps the global one, but refuses and explains itself unless it can prove nothing is lost: the project scope must install no component the global scope excludes, both scopes must have answered the pack's prompts identically, and every file it would delete must still match the hash recorded at install.That last gate matters because pack removal deletes tracked files without consulting the recorded hash (#365), so an edited skill would otherwise be destroyed. Here drift makes the pack un-auto-fixable instead.
Two notes for review. This is the first
fix()to drive the sync engine, which the responsibility boundary inCoreDoctorChecks.swiftdid not previously sanction — that comment now describes the category and the higher bar it carries. And removing a pack from one scope currently strips global gitignore lines another scope still claims, because reference counting covers only brew packages and plugins; this compensates on the doctor path and leaves a comment describing the general fix, which is also a live bug inmcs syncdeselection andmcs pack removetoday.Test plan
swift testpasses locallyswiftformat --lint .andswiftlintpass without violationsmcs sync,mcs doctor)Manual verification:
mcs sync --globaland select a pack, thenmcs syncin a project and select the same one →mcs doctorreports it under Project, naming the duplicated surfaces.mcs doctor --fix→ the fix appears under "Available fixes"; after confirming, re-running doctor reports nothing and the pack's global artifacts still verify.mcs doctor --fix→ expect a refusal naming the edited path, with the file untouched.mcs sync --global→ expect no duplication entries at all.Checklist for engine changes
fix()implementation does cleanup/migration only — never installs or registers resources — with a documented exception: this fix removes a pack from one scope by callingConfigurator.unconfigurePackrather than re-implementing removal. It installs nothing, and the three gates above are what justify the wider blast radius.LifecycleIntegrationTestsorDoctorRunnerIntegrationTests)PathContainment.safePath()and handle thenil(escape) case — deletion goes through the existing strategy path, which already doesCLAUDE.md,docs/,techpack.yamlschema inExternalPackManifest.swift)https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN