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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ mcs config set <key> <value> # Set a configuration value (true/false)
- `CoreDoctorChecks.swift` — check structs (CommandCheck, MCPServerCheck, PluginCheck, HookCheck, GitignoreCheck, CommandFileCheck, FileExistsCheck, FileContentCheck, HookSettingsCheck, SettingsKeysCheck, SettingsDriftCheck, PackGitignoreCheck, ProjectIndexCheck)
- `DerivedDoctorChecks.swift` — `deriveDoctorCheck()` extension on ComponentDefinition
- `ProjectDoctorChecks.swift` — project-scoped checks (CLAUDE.local.md freshness, state file)
- `ScopeDuplicationCheck.swift` — flags a pack configured in both the project and global scope, reporting only artifacts that genuinely exist twice; `--fix` removes the project copy via `Configurator.unconfigurePack`, gated on component subset, prompt-answer parity, and the recorded hash of every file it would delete
- `SectionValidator.swift` — validation of CLAUDE.local.md section markers

### Commands (`Sources/mcs/Commands/`)
Expand Down
32 changes: 32 additions & 0 deletions Sources/mcs/Core/FileHasher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ enum FileHasher {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}

/// How a file on disk compares to a hash recorded when it was installed.
///
/// Callers map these onto their own vocabulary — `FileContentCheck` reports them, and
/// `ScopeDuplicationCheck` refuses to delete anything that is not `.matches` — but the
/// policy for *what counts as drift* lives here so the two cannot disagree. A disagreement
/// would mean deleting a file doctor elsewhere reports as user-modified.
enum DriftState {
case matches
case missing
case directory
case changed
case unreadable(any Error)
}

/// Compare a file against its recorded hash.
///
/// A missing file has nothing to compare and a directory is covered by the entries for the
/// files inside it, so neither is drift. An unreadable file is reported rather than assumed
/// intact — the caller decides how cautious to be.
static func drift(of url: URL, expecting expectedHash: String) -> DriftState {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
return .missing
}
if isDirectory.boolValue { return .directory }
do {
return try sha256(of: url) == expectedHash ? .matches : .changed
} catch {
return .unreadable(error)
}
}

/// Result of hashing all files in a directory, with per-file error resilience.
struct DirectoryHashResult {
let hashes: [(relativePath: String, hash: String)]
Expand Down
12 changes: 12 additions & 0 deletions Sources/mcs/Core/ProjectIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ struct ProjectIndex {
data.projects.removeAll { $0.path == projectPath }
}

/// Remove a specific pack from one project entry, pruning the entry if nothing remains.
///
/// Deliberately not expressed as `upsert` with the surviving packs: `upsert` stamps
/// `lastSynced` with the current time, which would claim a sync that never happened.
func removePack(_ packID: String, fromProject path: String, in data: inout IndexData) {
guard let index = data.projects.firstIndex(where: { $0.path == path }) else { return }
data.projects[index].packs.removeAll { $0 == packID }
if data.projects[index].packs.isEmpty {
data.projects.remove(at: index)
}
}

/// Remove a specific pack from all project entries. Prunes entries with no remaining packs.
func removePack(_ packID: String, from data: inout IndexData) {
for i in data.projects.indices {
Expand Down
34 changes: 18 additions & 16 deletions Sources/mcs/Doctor/CoreDoctorChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import Foundation
// - **Cleanup**: Removing deprecated components (MCP servers, plugins)
// - **Migration**: One-time data moves (state files)
// - **Trivial repairs**: Permission fixes (chmod), gitignore additions (idempotent)
// - **Scope reconciliation**: Removing a pack from one scope when a provably equivalent copy
// exists in another, by calling `Configurator.unconfigurePack` rather than re-implementing
// removal. This is the one category that drives the sync engine, so it carries a higher bar:
// the check must refuse the fix unless it can prove nothing is lost — see
// `ScopeDuplicationCheck`, which gates on component subset, prompt-answer parity, and the
// recorded hash of every file it would delete. Do not copy the pattern without the gates.
//
// `doctor --fix` does NOT handle:
// - **Additive operations**: Installing packages, registering servers, copying hooks/skills/commands.
// These are `mcs sync`'s responsibility.
//
// This separation keeps `doctor --fix` predictable and non-destructive.
// This separation keeps `doctor --fix` predictable, and destructive only where it can show its work.

struct CommandCheck: DoctorCheck {
let name: String
Expand Down Expand Up @@ -196,21 +202,17 @@ struct FileContentCheck: DoctorCheck {
let expectedHash: String

func check() -> CheckResult {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path.path, isDirectory: &isDir) else {
return .skip("missing (checked separately)")
}
if isDir.boolValue {
return .skip("directory (contents checked individually)")
}
do {
let currentHash = try FileHasher.sha256(of: path)
if currentHash == expectedHash {
return .pass("content matches")
}
return .warn("modified since last sync")
} catch {
return .fail("could not read file: \(error.localizedDescription)")
switch FileHasher.drift(of: path, expecting: expectedHash) {
case .matches:
.pass("content matches")
case .missing:
.skip("missing (checked separately)")
case .directory:
.skip("directory (contents checked individually)")
case .changed:
.warn("modified since last sync")
case let .unreadable(error):
.fail("could not read file: \(error.localizedDescription)")
}
}

Expand Down
26 changes: 24 additions & 2 deletions Sources/mcs/Doctor/DoctorRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ struct DoctorRunner {
let skipConfirmation: Bool
/// Explicit pack filter. If nil, uses packs from project state or pack registry.
let packFilter: String?

/// `packFilter` split into identifiers, so the comma convention is defined in one place.
///
/// Entries are trimmed and empties dropped: `--pack "ios, swift"` is a natural thing to type,
/// and an untrimmed `" swift"` matches no pack — the run would just report it as unregistered.
private var packFilterIDs: Set<String>? {
packFilter.map {
Set(
$0.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
)
}
}

/// When true, check only globally-configured packs (ignores project scope).
let globalOnly: Bool
let registry: TechPackRegistry
Expand Down Expand Up @@ -222,6 +237,14 @@ struct DoctorRunner {
let context = ProjectDoctorContext(projectRoot: root, registry: registry)
nonComponentChecks += ProjectDoctorChecks.checks(context: context)
}
// Packs configured in this project *and* globally. Self-skips when no global
// scope exists, so it costs nothing on machines that never ran `--global`.
nonComponentChecks += ScopeDuplicationCheck.checks(
projectRoot: root,
registry: registry,
environment: env,
packFilter: packFilterIDs
)
}

// Global-scoped template freshness check (always runs, self-skips if no global CLAUDE.md)
Expand Down Expand Up @@ -297,8 +320,7 @@ struct DoctorRunner {
globalExcludedComponentIDs: Set<String>
) -> [CheckScope] {
// --pack flag: single scope, use globalOnly to determine effective root
if let filter = packFilter {
let packIDs = Set(filter.components(separatedBy: ","))
if let packIDs = packFilterIDs {
let effectiveRoot = globalOnly ? nil : projectRoot
// Load artifacts and excluded components from the appropriate state
var artifacts: [String: PackArtifactRecord] = [:]
Expand Down
Loading
Loading