From 71a6d255cc3d80c445886f051922d29164f943ea Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Fri, 27 Mar 2026 13:48:54 +0100 Subject: [PATCH 1/2] Fix PluginCheck to support project-scoped enabledPlugins (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add two-tier lookup (project settings.local.json → global settings.json) matching MCPServerCheck pattern - Extract Constants.FileNames.settingsLocal to replace 5 string literals across 4 files - Add 7 unit tests, 2 derived-check tests, and 1 integration test (910 total) --- Sources/mcs/Core/Constants.swift | 3 + Sources/mcs/Doctor/CoreDoctorChecks.swift | 34 +++- Sources/mcs/Doctor/DerivedDoctorChecks.swift | 2 +- Sources/mcs/Doctor/DoctorRunner.swift | 2 +- .../mcs/Export/ConfigurationDiscovery.swift | 2 +- Sources/mcs/Sync/ProjectSyncStrategy.swift | 2 +- Sources/mcs/Sync/SyncScope.swift | 2 +- .../CoreDoctorCheckSandboxTests.swift | 182 +++++++++++++++++- Tests/MCSTests/DerivedDoctorCheckTests.swift | 24 +++ .../DoctorRunnerIntegrationTests.swift | 48 +++++ 10 files changed, 287 insertions(+), 14 deletions(-) diff --git a/Sources/mcs/Core/Constants.swift b/Sources/mcs/Core/Constants.swift index e49cf25..4106964 100644 --- a/Sources/mcs/Core/Constants.swift +++ b/Sources/mcs/Core/Constants.swift @@ -31,6 +31,9 @@ enum Constants { /// The update check cache file (timestamp + results). static let updateCheckCache = "update-check.json" + /// The per-project settings file written by `mcs sync`. + static let settingsLocal = "settings.local.json" + /// The user preferences file. static let mcsConfig = "config.yaml" } diff --git a/Sources/mcs/Doctor/CoreDoctorChecks.swift b/Sources/mcs/Doctor/CoreDoctorChecks.swift index 5bd2b79..84b1d6b 100644 --- a/Sources/mcs/Doctor/CoreDoctorChecks.swift +++ b/Sources/mcs/Doctor/CoreDoctorChecks.swift @@ -91,7 +91,14 @@ struct MCPServerCheck: DoctorCheck { struct PluginCheck: DoctorCheck { let pluginRef: PluginRef - var environment: Environment = .init() + let projectRoot: URL? + let environment: Environment + + init(pluginRef: PluginRef, projectRoot: URL? = nil, environment: Environment = Environment()) { + self.pluginRef = pluginRef + self.projectRoot = projectRoot + self.environment = environment + } var name: String { pluginRef.bareName @@ -102,8 +109,30 @@ struct PluginCheck: DoctorCheck { } func check() -> CheckResult { + var projectSettingsCorrupt = false + + // Tier 1: Project-scoped settings.local.json + if let root = projectRoot { + let projectSettingsURL = root + .appendingPathComponent(Constants.FileNames.claudeDirectory) + .appendingPathComponent(Constants.FileNames.settingsLocal) + do { + let projectSettings = try Settings.load(from: projectSettingsURL) + if projectSettings.enabledPlugins?[pluginRef.bareName] == true { + return .pass("enabled (project)") + } + } catch { + // Corrupt project settings — fall through to global, but note for diagnostics + projectSettingsCorrupt = true + } + } + + // Tier 2: Global settings.json let settingsURL = environment.claudeSettings guard FileManager.default.fileExists(atPath: settingsURL.path) else { + if projectSettingsCorrupt { + return .fail("settings.local.json is corrupt and settings.json not found") + } return .fail("settings.json not found") } let settings: Settings @@ -115,6 +144,9 @@ struct PluginCheck: DoctorCheck { if settings.enabledPlugins?[pluginRef.bareName] == true { return .pass("enabled") } + if projectSettingsCorrupt { + return .fail("not enabled (settings.local.json is corrupt)") + } return .fail("not enabled") } diff --git a/Sources/mcs/Doctor/DerivedDoctorChecks.swift b/Sources/mcs/Doctor/DerivedDoctorChecks.swift index 2be3523..9ebca02 100644 --- a/Sources/mcs/Doctor/DerivedDoctorChecks.swift +++ b/Sources/mcs/Doctor/DerivedDoctorChecks.swift @@ -12,7 +12,7 @@ extension ComponentDefinition { return MCPServerCheck(name: displayName, serverName: config.name, projectRoot: projectRoot, environment: environment) case let .plugin(pluginName): - return PluginCheck(pluginRef: PluginRef(pluginName), environment: environment) + return PluginCheck(pluginRef: PluginRef(pluginName), projectRoot: projectRoot, environment: environment) case let .brewInstall(package): return CommandCheck( diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 7a82c79..d4b53fd 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -447,7 +447,7 @@ struct DoctorRunner { if !artifacts.hookCommands.isEmpty || !artifacts.settingsKeys.isEmpty { let settingsPath: URL = if let root = scope.effectiveProjectRoot { root.appendingPathComponent(Constants.FileNames.claudeDirectory) - .appendingPathComponent("settings.local.json") + .appendingPathComponent(Constants.FileNames.settingsLocal) } else { env.claudeSettings } diff --git a/Sources/mcs/Export/ConfigurationDiscovery.swift b/Sources/mcs/Export/ConfigurationDiscovery.swift index 5d2b03c..151526c 100644 --- a/Sources/mcs/Export/ConfigurationDiscovery.swift +++ b/Sources/mcs/Export/ConfigurationDiscovery.swift @@ -93,7 +93,7 @@ struct ConfigurationDiscovery { agentsDir = environment.agentsDirectory case let .project(projectRoot): let claudeDir = projectRoot.appendingPathComponent(Constants.FileNames.claudeDirectory) - settingsPath = claudeDir.appendingPathComponent("settings.local.json") + settingsPath = claudeDir.appendingPathComponent(Constants.FileNames.settingsLocal) claudeFilePath = projectRoot.appendingPathComponent(Constants.FileNames.claudeLocalMD) hooksDir = claudeDir.appendingPathComponent("hooks") skillsDir = claudeDir.appendingPathComponent("skills") diff --git a/Sources/mcs/Sync/ProjectSyncStrategy.swift b/Sources/mcs/Sync/ProjectSyncStrategy.swift index 0809fd8..1dc8c67 100644 --- a/Sources/mcs/Sync/ProjectSyncStrategy.swift +++ b/Sources/mcs/Sync/ProjectSyncStrategy.swift @@ -163,7 +163,7 @@ struct ProjectSyncStrategy: SyncStrategy { output.error("Could not write settings.local.json: \(error.localizedDescription)") output.error("Hooks and plugins will not be active. Re-run '\(scope.syncHint)' after fixing the issue.") throw MCSError.fileOperationFailed( - path: "settings.local.json", + path: Constants.FileNames.settingsLocal, reason: error.localizedDescription ) } diff --git a/Sources/mcs/Sync/SyncScope.swift b/Sources/mcs/Sync/SyncScope.swift index 9b199c7..d80cdc5 100644 --- a/Sources/mcs/Sync/SyncScope.swift +++ b/Sources/mcs/Sync/SyncScope.swift @@ -66,7 +66,7 @@ extension SyncScope { label: "Project", targetPath: claudeDir, stateFile: claudeDir.appendingPathComponent(Constants.FileNames.mcsProject), - settingsPath: claudeDir.appendingPathComponent("settings.local.json"), + settingsPath: claudeDir.appendingPathComponent(Constants.FileNames.settingsLocal), claudeFilePath: projectPath.appendingPathComponent(Constants.FileNames.claudeLocalMD), scopeIdentifier: projectPath.path, mcpScopeOverride: nil, diff --git a/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift b/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift index aeb6632..620c2c9 100644 --- a/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift +++ b/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift @@ -240,8 +240,7 @@ struct PluginCheckSandboxTests { """ try settings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) - var check = PluginCheck(pluginRef: PluginRef("pr-review-toolkit")) - check.environment = env + let check = PluginCheck(pluginRef: PluginRef("pr-review-toolkit"), environment: env) let result = check.check() guard case .pass = result else { Issue.record("Expected .pass, got \(result)") @@ -264,8 +263,7 @@ struct PluginCheckSandboxTests { """ try settings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) - var check = PluginCheck(pluginRef: PluginRef("missing-plugin")) - check.environment = env + let check = PluginCheck(pluginRef: PluginRef("missing-plugin"), environment: env) let result = check.check() guard case .fail = result else { Issue.record("Expected .fail, got \(result)") @@ -280,14 +278,183 @@ struct PluginCheckSandboxTests { let env = Environment(home: home) // Don't create settings.json - var check = PluginCheck(pluginRef: PluginRef("my-plugin")) - check.environment = env + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), environment: env) let result = check.check() guard case .fail = result else { Issue.record("Expected .fail, got \(result)") return } } + + // MARK: - Project-scoped tests + + @Test("pass when plugin is enabled in project settings.local.json") + func passWhenEnabledInProjectSettings() throws { + let home = try makeGlobalTmpDir(label: "plugin-project-pass") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + let projectSettings = """ + { + "enabledPlugins": { + "my-plugin": true + } + } + """ + try projectSettings.write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + // No global settings.json + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .pass(msg) = result else { + Issue.record("Expected .pass, got \(result)") + return + } + #expect(msg == "enabled (project)") + } + + @Test("pass via global fallback when plugin not in project settings") + func passWhenEnabledGloballyButNotInProject() throws { + let home = try makeGlobalTmpDir(label: "plugin-global-fallback") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + // Project settings without the target plugin + let projectSettings = """ + { + "enabledPlugins": { + "other-plugin": true + } + } + """ + try projectSettings.write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + // Global settings with the target plugin + let globalSettings = """ + { + "enabledPlugins": { + "my-plugin": true + } + } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .pass(msg) = result else { + Issue.record("Expected .pass, got \(result)") + return + } + #expect(msg == "enabled") + } + + @Test("fail when plugin not enabled in either scope") + func failWhenNotEnabledInEitherScope() throws { + let home = try makeGlobalTmpDir(label: "plugin-both-fail") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + let projectSettings = """ + { "enabledPlugins": { "other-plugin": true } } + """ + try projectSettings.write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + let globalSettings = """ + { "enabledPlugins": { "another-plugin": true } } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case .fail = result else { + Issue.record("Expected .fail, got \(result)") + return + } + } + + @Test("pass via global fallback when project settings.local.json is invalid") + func passWhenProjectSettingsInvalidFallsBackToGlobal() throws { + let home = try makeGlobalTmpDir(label: "plugin-invalid-project") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + // Invalid project settings + try "not valid json".write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + // Valid global settings + let globalSettings = """ + { + "enabledPlugins": { + "my-plugin": true + } + } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .pass(msg) = result else { + Issue.record("Expected .pass, got \(result)") + return + } + #expect(msg == "enabled") + } + + @Test("pass via global when projectRoot set but no settings.local.json exists") + func passWhenProjectSettingsAbsentFallsBackToGlobal() throws { + let home = try makeGlobalTmpDir(label: "plugin-no-project-settings") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + let globalSettings = """ + { + "enabledPlugins": { + "my-plugin": true + } + } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .pass(msg) = result else { + Issue.record("Expected .pass, got \(result)") + return + } + #expect(msg == "enabled") + } } // MARK: - HookCheck Sandbox Tests @@ -678,8 +845,7 @@ extension PluginCheckSandboxTests { try "not valid json".write(to: env.claudeSettings, atomically: true, encoding: .utf8) - var check = PluginCheck(pluginRef: PluginRef("my-plugin")) - check.environment = env + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), environment: env) let result = check.check() guard case let .fail(msg) = result else { Issue.record("Expected .fail, got \(result)") diff --git a/Tests/MCSTests/DerivedDoctorCheckTests.swift b/Tests/MCSTests/DerivedDoctorCheckTests.swift index c5a63d9..cfdbe2e 100644 --- a/Tests/MCSTests/DerivedDoctorCheckTests.swift +++ b/Tests/MCSTests/DerivedDoctorCheckTests.swift @@ -247,6 +247,30 @@ struct DerivedDoctorCheckTests { #expect(mcpCheck?.projectRoot == nil) } + // MARK: - PluginCheck project root + + @Test("plugin action passes projectRoot to PluginCheck") + func pluginDerivationWithProjectRoot() { + let projectRoot = URL(fileURLWithPath: "/tmp/my-project") + let component = makeComponent( + type: .plugin, + installAction: .plugin(name: "my-plugin") + ) + let pluginCheck = component.deriveDoctorCheck(projectRoot: projectRoot) as? PluginCheck + #expect(pluginCheck != nil) + #expect(pluginCheck?.projectRoot?.path == "/tmp/my-project") + } + + @Test("plugin action without projectRoot has nil projectRoot") + func pluginDerivationWithoutProjectRoot() { + let component = makeComponent( + type: .plugin, + installAction: .plugin(name: "my-plugin") + ) + let pluginCheck = component.deriveDoctorCheck() as? PluginCheck + #expect(pluginCheck?.projectRoot == nil) + } + // MARK: - allDoctorChecks combines derived + supplementary @Test("allDoctorChecks returns derived + supplementary") diff --git a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift index 75d1ffe..1921f4b 100644 --- a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift +++ b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift @@ -163,6 +163,54 @@ struct DoctorRunnerIntegrationTests { try runner.run() } + @Test("PluginCheck passes when plugin is enabled in project settings.local.json") + func pluginCheckPassesWithProjectSettings() throws { + let (home, project) = try makeSandboxProject(label: "runner-plugin-project") + defer { try? FileManager.default.removeItem(at: home) } + + let pluginComponent = ComponentDefinition( + id: "test-pack.my-plugin", + displayName: "My Plugin", + description: "Test plugin", + type: .plugin, + packIdentifier: "test-pack", + dependencies: [], + isRequired: true, + installAction: .plugin(name: "my-plugin") + ) + let pack = MockTechPack( + identifier: "test-pack", + displayName: "Test Pack", + components: [pluginComponent] + ) + let registry = TechPackRegistry(packs: [pack]) + + // Write project state + var state = try ProjectState(projectRoot: project) + state.recordPack("test-pack") + try state.save() + + // Write plugin enablement to project-scoped settings.local.json only + let claudeDir = project.appendingPathComponent(Constants.FileNames.claudeDirectory) + let projectSettings = """ + { + "enabledPlugins": { + "my-plugin": true + } + } + """ + try projectSettings.write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + // No global settings.json — plugin is only project-scoped + + var runner = makeRunner(home: home, projectRoot: project, registry: registry) + // Should complete without error — PluginCheck should find the plugin + // in project-scoped settings.local.json + try runner.run() + } + @Test("MCPServerCheck passes via walk-up when project root is a subdirectory of git root") func mcpCheckWalksUpToGitRoot() throws { let home = try makeGlobalTmpDir(label: "runner-walkup") From 89886e1eda2b690f4d0450558bf2df943c3a1b6a Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Fri, 27 Mar 2026 14:05:13 +0100 Subject: [PATCH 2/2] Surface project settings corruption in PluginCheck diagnostics - Change projectSettingsCorrupt Bool to projectSettingsError String? to include error details - Return .warn when plugin enabled globally but project settings.local.json is corrupt - Add tests for explicit false values, corrupt-with-no-global, and corrupt-with-absent-plugin --- Sources/mcs/Doctor/CoreDoctorChecks.swift | 11 ++- .../CoreDoctorCheckSandboxTests.swift | 96 ++++++++++++++++++- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/Sources/mcs/Doctor/CoreDoctorChecks.swift b/Sources/mcs/Doctor/CoreDoctorChecks.swift index 84b1d6b..3a2fb34 100644 --- a/Sources/mcs/Doctor/CoreDoctorChecks.swift +++ b/Sources/mcs/Doctor/CoreDoctorChecks.swift @@ -109,7 +109,7 @@ struct PluginCheck: DoctorCheck { } func check() -> CheckResult { - var projectSettingsCorrupt = false + var projectSettingsError: String? // Tier 1: Project-scoped settings.local.json if let root = projectRoot { @@ -123,14 +123,14 @@ struct PluginCheck: DoctorCheck { } } catch { // Corrupt project settings — fall through to global, but note for diagnostics - projectSettingsCorrupt = true + projectSettingsError = error.localizedDescription } } // Tier 2: Global settings.json let settingsURL = environment.claudeSettings guard FileManager.default.fileExists(atPath: settingsURL.path) else { - if projectSettingsCorrupt { + if projectSettingsError != nil { return .fail("settings.local.json is corrupt and settings.json not found") } return .fail("settings.json not found") @@ -142,9 +142,12 @@ struct PluginCheck: DoctorCheck { return .fail("settings.json is invalid: \(error.localizedDescription)") } if settings.enabledPlugins?[pluginRef.bareName] == true { + if let errorDesc = projectSettingsError { + return .warn("enabled (global) — settings.local.json is unreadable: \(errorDesc)") + } return .pass("enabled") } - if projectSettingsCorrupt { + if projectSettingsError != nil { return .fail("not enabled (settings.local.json is corrupt)") } return .fail("not enabled") diff --git a/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift b/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift index 620c2c9..393e108 100644 --- a/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift +++ b/Tests/MCSTests/CoreDoctorCheckSandboxTests.swift @@ -393,8 +393,8 @@ struct PluginCheckSandboxTests { } } - @Test("pass via global fallback when project settings.local.json is invalid") - func passWhenProjectSettingsInvalidFallsBackToGlobal() throws { + @Test("warn via global fallback when project settings.local.json is invalid") + func warnWhenProjectSettingsInvalidFallsBackToGlobal() throws { let home = try makeGlobalTmpDir(label: "plugin-invalid-project") defer { try? FileManager.default.removeItem(at: home) } let env = Environment(home: home) @@ -421,11 +421,11 @@ struct PluginCheckSandboxTests { let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) let result = check.check() - guard case let .pass(msg) = result else { - Issue.record("Expected .pass, got \(result)") + guard case let .warn(msg) = result else { + Issue.record("Expected .warn, got \(result)") return } - #expect(msg == "enabled") + #expect(msg.contains("settings.local.json is unreadable")) } @Test("pass via global when projectRoot set but no settings.local.json exists") @@ -455,6 +455,92 @@ struct PluginCheckSandboxTests { } #expect(msg == "enabled") } + + @Test("pass via global when plugin explicitly false in project settings") + func passWhenPluginExplicitlyFalseInProject() throws { + let home = try makeGlobalTmpDir(label: "plugin-false-project") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + let projectSettings = """ + { "enabledPlugins": { "my-plugin": false } } + """ + try projectSettings.write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + let globalSettings = """ + { "enabledPlugins": { "my-plugin": true } } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .pass(msg) = result else { + Issue.record("Expected .pass, got \(result)") + return + } + #expect(msg == "enabled") + } + + @Test("fail with corrupt message when project settings invalid and no global settings") + func failWhenProjectCorruptAndNoGlobal() throws { + let home = try makeGlobalTmpDir(label: "plugin-corrupt-no-global") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try "not valid json".write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .fail(msg) = result else { + Issue.record("Expected .fail, got \(result)") + return + } + #expect(msg.contains("settings.local.json is corrupt")) + #expect(msg.contains("settings.json not found")) + } + + @Test("fail with corrupt message when project settings invalid and plugin not in global") + func failWhenProjectCorruptAndPluginNotInGlobal() throws { + let home = try makeGlobalTmpDir(label: "plugin-corrupt-not-global") + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let projectRoot = home.appendingPathComponent("my-project") + let claudeDir = projectRoot.appendingPathComponent(".claude") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try "not valid json".write( + to: claudeDir.appendingPathComponent("settings.local.json"), + atomically: true, encoding: .utf8 + ) + + let globalSettings = """ + { "enabledPlugins": { "other-plugin": true } } + """ + try globalSettings.write(to: env.claudeSettings, atomically: true, encoding: .utf8) + + let check = PluginCheck(pluginRef: PluginRef("my-plugin"), projectRoot: projectRoot, environment: env) + let result = check.check() + guard case let .fail(msg) = result else { + Issue.record("Expected .fail, got \(result)") + return + } + #expect(msg == "not enabled (settings.local.json is corrupt)") + } } // MARK: - HookCheck Sandbox Tests