From 637ffeba5291785a23bfb3644c0a704400268678 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 20 Oct 2026 00:03:22 +0200 Subject: [PATCH] ISSUE-454: Skip CLAUDE file freshness when mcs never wrote sections there - A marker-free CLAUDE.md / CLAUDE.local.md is skipped unless a configured pack recorded template sections - Recorded sections with missing markers still warn as drift; legacy packs without a record count as recorded - Regression and lifecycle tests for the project-only bootstrap case --- Sources/mcs/Doctor/DoctorRunner.swift | 2 +- Sources/mcs/Doctor/SectionValidator.swift | 23 ++++-- .../CLAUDELocalFreshnessCheckTests.swift | 72 +++++++++++++++++++ .../MCSTests/LifecycleIntegrationTests.swift | 26 +++++++ Tests/MCSTests/ProjectDetectorTests.swift | 8 +-- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 988463ae..48c6ef0d 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -285,7 +285,7 @@ struct DoctorRunner { ) } - // Global-scoped template freshness check (always runs, self-skips if no global CLAUDE.md) + // Global-scoped template freshness check (always runs, self-skips unless mcs composed sections into the global CLAUDE.md) nonComponentChecks.append(CLAUDEMDFreshnessCheck( fileURL: env.globalClaudeMD, stateLoader: { try ProjectState(stateFile: env.globalStateFile) }, diff --git a/Sources/mcs/Doctor/SectionValidator.swift b/Sources/mcs/Doctor/SectionValidator.swift index dabcfccd..37926ef4 100644 --- a/Sources/mcs/Doctor/SectionValidator.swift +++ b/Sources/mcs/Doctor/SectionValidator.swift @@ -171,11 +171,6 @@ struct CLAUDEMDFreshnessCheck: DoctorCheck { return .fail("\(fileName) exists but could not be read: \(error.localizedDescription)") } - let sections = TemplateComposer.parseSections(from: content) - guard !sections.isEmpty else { - return .warn("\(fileName) has no mcs section markers — run '\(syncHint)'") - } - let state: ProjectState do { state = try stateLoader() @@ -183,6 +178,16 @@ struct CLAUDEMDFreshnessCheck: DoctorCheck { return .warn("could not read state: \(error.localizedDescription) — run '\(syncHint)' to regenerate") } + let sections = TemplateComposer.parseSections(from: content) + guard !sections.isEmpty else { + // Missing markers are only drift if mcs wrote sections here; a hand-written + // CLAUDE.md in a scope mcs never composed into is not ours to report on. + guard hasRecordedTemplateSections(state) else { + return .skip("no mcs-managed sections in \(fileName)") + } + return .warn("\(fileName) has no mcs section markers — run '\(syncHint)'") + } + guard let storedValues = state.resolvedValues else { return .warn("no stored values for verification — run '\(syncHint)'") } @@ -259,6 +264,14 @@ struct CLAUDEMDFreshnessCheck: DoctorCheck { // MARK: - Helpers + /// A pack without an artifact record predates section tracking, so it may have written any of its sections. + private func hasRecordedTemplateSections(_ state: ProjectState) -> Bool { + state.configuredPacks.contains { packID in + guard let record = state.artifacts(for: packID) else { return true } + return !record.templateSections.isEmpty + } + } + /// Build expected sections by re-rendering current pack templates with stored values. private func buildExpectedSections( state: ProjectState, diff --git a/Tests/MCSTests/CLAUDELocalFreshnessCheckTests.swift b/Tests/MCSTests/CLAUDELocalFreshnessCheckTests.swift index a903c39d..601c1372 100644 --- a/Tests/MCSTests/CLAUDELocalFreshnessCheckTests.swift +++ b/Tests/MCSTests/CLAUDELocalFreshnessCheckTests.swift @@ -614,6 +614,78 @@ struct CLAUDEMDFreshnessCheckTests { Issue.record("Expected failed but got \(fixResult)") } } + + // MARK: - Unmarked file + + private func writeUnmarkedClaudeLocal(at projectRoot: URL) throws { + try "# Hand-written instructions\n".write( + to: projectRoot.appendingPathComponent(Constants.FileNames.claudeLocalMD), + atomically: true, + encoding: .utf8 + ) + } + + @Test("Unmarked file — skips when configured packs recorded no template sections") + func unmarkedFileSkipsWithoutRecordedSections() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + try writeUnmarkedClaudeLocal(at: tmpDir) + try writeProjectState( + at: tmpDir, + packs: ["mcp-pack"], + artifacts: ["mcp-pack": PackArtifactRecord(mcpServers: [MCPServerRef(name: "srv", scope: "local")])] + ) + + let check = makeProjectCheck(projectRoot: tmpDir, registry: makeRegistry(packs: [])) + + let result = check.check() + if case .skip = result { + // expected + } else { + Issue.record("Expected skip but got \(result)") + } + } + + @Test("Unmarked file — warns when state records sections the file no longer has") + func unmarkedFileWarnsWithRecordedSections() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + try writeUnmarkedClaudeLocal(at: tmpDir) + try writeProjectState( + at: tmpDir, + packs: ["test-pack"], + artifacts: ["test-pack": PackArtifactRecord(templateSections: ["test-pack.section-a"])] + ) + + let check = makeProjectCheck(projectRoot: tmpDir, registry: makeRegistry(packs: [])) + + let result = check.check() + if case let .warn(msg) = result { + #expect(msg.contains("no mcs section markers")) + } else { + Issue.record("Expected warn but got \(result)") + } + } + + @Test("Unmarked file — warns for a legacy pack with no artifact record") + func unmarkedFileWarnsForLegacyPack() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + try writeUnmarkedClaudeLocal(at: tmpDir) + try writeProjectState(at: tmpDir, packs: ["legacy-pack"]) + + let check = makeProjectCheck(projectRoot: tmpDir, registry: makeRegistry(packs: [])) + + let result = check.check() + if case let .warn(msg) = result { + #expect(msg.contains("no mcs section markers")) + } else { + Issue.record("Expected warn but got \(result)") + } + } } // MARK: - Test doubles diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index eb3743a0..b88a6240 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -1087,6 +1087,32 @@ struct GlobalScopeLifecycleTests { // === Doctor passes === #expect(try bed.runGlobalDoctor(registry: registry).issues == 0) } + + @Test("A hand-written global CLAUDE.md adds no doctor findings after a project-only sync") + func handWrittenGlobalClaudeMDAfterProjectSync() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let pack = MockTechPack( + identifier: "project-pack", + displayName: "Project Pack", + components: [bed.mcpComponent(pack: "project-pack", id: "srv", name: "srv")] + ) + let registry = TechPackRegistry(packs: [pack]) + try bed.makeConfigurator(registry: registry).configure(packs: [pack], confirmRemovals: false) + + let baseline = try bed.runDoctor(registry: registry) + + try FileManager.default.createDirectory( + at: bed.env.globalClaudeMD.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "# My own instructions\n".write(to: bed.env.globalClaudeMD, atomically: true, encoding: .utf8) + + let summary = try bed.runDoctor(registry: registry) + #expect(summary.warnings == baseline.warnings) + #expect(summary.issues == baseline.issues) + } } // MARK: - Scenario 5c: Global install warns about projects that already hold the pack diff --git a/Tests/MCSTests/ProjectDetectorTests.swift b/Tests/MCSTests/ProjectDetectorTests.swift index b34827f0..51cda7b5 100644 --- a/Tests/MCSTests/ProjectDetectorTests.swift +++ b/Tests/MCSTests/ProjectDetectorTests.swift @@ -202,8 +202,8 @@ struct ProjectDoctorCheckTests { } } - @Test("CLAUDEMDFreshnessCheck warns when no section markers") - func freshnessCheckWarnsNoMarkers() throws { + @Test("CLAUDEMDFreshnessCheck skips an unmarked file mcs never synced into") + func freshnessCheckSkipsUnmarkedUnsyncedFile() throws { let tmpDir = try makeTmpDir() defer { try? FileManager.default.removeItem(at: tmpDir) } @@ -219,10 +219,10 @@ struct ProjectDoctorCheckTests { displayName: "CLAUDE.local.md freshness", syncHint: "mcs sync" ) - if case .warn = check.check() { + if case .skip = check.check() { // expected } else { - #expect(Bool(false), "Expected .warn result") + #expect(Bool(false), "Expected .skip result") } }