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
2 changes: 1 addition & 1 deletion Sources/mcs/Doctor/DoctorRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
23 changes: 18 additions & 5 deletions Sources/mcs/Doctor/SectionValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,23 @@ 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()
} catch {
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)'")
}
Expand Down Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions Tests/MCSTests/CLAUDELocalFreshnessCheckTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Tests/MCSTests/LifecycleIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Tests/MCSTests/ProjectDetectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand All @@ -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")
}
}

Expand Down
Loading