Skip to content

Commit 6d3dc0f

Browse files
authored
Count all warnings in mcs doctor summary (#352)
- Inject a shared WarningCounter into CLIOutput so advisories emitted outside the check loop (collision renames, unregistered packs) are tallied - Return DoctorSummary from DoctorRunner.run() and add regression tests
1 parent 1eb76a3 commit 6d3dc0f

3 files changed

Lines changed: 159 additions & 9 deletions

File tree

Sources/mcs/Core/CLIOutput.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
11
import Foundation
2+
import os
3+
4+
/// Shared, mutable tally of warnings emitted through a `CLIOutput`.
5+
///
6+
/// A reference type so that value-type `CLIOutput` copies (e.g. the one handed
7+
/// to `DestinationCollisionResolver`) all increment the same counter. Lets a
8+
/// caller like `DoctorRunner` faithfully count every warning shown — including
9+
/// advisories emitted outside the doctor check loop.
10+
///
11+
/// `Sendable` so `CLIOutput` stays `Sendable` (it's captured in isolated
12+
/// closures, e.g. via `ScriptRunner`); the lock supplies that guarantee.
13+
final class WarningCounter: Sendable {
14+
private let lock = OSAllocatedUnfairLock(initialState: 0)
15+
16+
var count: Int {
17+
lock.withLock { $0 }
18+
}
19+
20+
func increment() {
21+
lock.withLock { $0 += 1 }
22+
}
23+
}
224

325
/// Terminal output with ANSI color support and structured logging.
426
struct CLIOutput {
@@ -10,8 +32,11 @@ struct CLIOutput {
1032
/// manipulation, ANSI ornamentation) can render. Gate pickers on this.
1133
let isInteractiveTerminal: Bool
1234
let style: ANSIStyle
35+
/// Optional tally that `warn(_:)` increments. `nil` for most callers; set by
36+
/// callers (e.g. `DoctorRunner`) that need to count emitted warnings.
37+
let warningCounter: WarningCounter?
1338

14-
init(colorsEnabled: Bool? = nil) {
39+
init(colorsEnabled: Bool? = nil, warningCounter: WarningCounter? = nil) {
1540
if let explicit = colorsEnabled {
1641
self.colorsEnabled = explicit
1742
} else {
@@ -20,6 +45,7 @@ struct CLIOutput {
2045
hasInteractiveStdin = isatty(STDIN_FILENO) != 0
2146
isInteractiveTerminal = hasInteractiveStdin && isatty(STDOUT_FILENO) != 0
2247
style = ANSIStyle(enabled: self.colorsEnabled)
48+
self.warningCounter = warningCounter
2349
}
2450

2551
// MARK: - ANSI Codes (delegate to `style`)
@@ -131,6 +157,7 @@ struct CLIOutput {
131157
}
132158

133159
func warn(_ message: String) {
160+
warningCounter?.increment()
134161
write("\(yellow)[WARN]\(reset) \(message)\n")
135162
}
136163

Sources/mcs/Doctor/DoctorRunner.swift

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import Foundation
22

3+
/// Outcome tallies from a doctor run, captured at summary time.
4+
struct DoctorSummary {
5+
let passed: Int
6+
let warnings: Int
7+
let issues: Int
8+
}
9+
310
/// Orchestrates all doctor checks grouped by section, with optional fix mode.
411
///
512
/// **Scope of `--fix`**: Cleanup, migration, and trivial repairs only.
@@ -15,10 +22,13 @@ struct DoctorRunner {
1522
let globalOnly: Bool
1623
let registry: TechPackRegistry
1724

18-
private let output = CLIOutput()
25+
/// Counts every warning emitted through `output`, including advisories shown
26+
/// outside the check loop (collision renames, unregistered packs, unreadable
27+
/// state) so the summary tally is faithful to what the user saw.
28+
private let warningCounter = WarningCounter()
29+
private let output: CLIOutput
1930
private var passCount = 0
2031
private var failCount = 0
21-
private var warnCount = 0
2232
private var fixedCount = 0
2333
/// Failed checks collected during diagnosis, to be fixed after confirmation.
2434
private var pendingFixes: [any DoctorCheck] = []
@@ -60,9 +70,11 @@ struct DoctorRunner {
6070
self.registry = registry
6171
self.environment = environment
6272
self.projectRootOverride = projectRootOverride
73+
output = CLIOutput(warningCounter: warningCounter)
6374
}
6475

65-
mutating func run() throws {
76+
@discardableResult
77+
mutating func run() throws -> DoctorSummary {
6678
output.header("Managed Claude Stack — Doctor")
6779

6880
let env = environment
@@ -230,13 +242,19 @@ struct DoctorRunner {
230242
runChecks(checks)
231243
}
232244

233-
// Summary (before fixes, so the user sees the full picture first)
245+
// Summary (before fixes, so the user sees the full picture first).
246+
// Capture once: fix-phase warnings (below) must not alter the reported total.
247+
let summary = DoctorSummary(
248+
passed: passCount,
249+
warnings: warningCounter.count,
250+
issues: failCount
251+
)
234252
output.header("Summary")
235253
output.doctorSummary(
236-
passed: passCount,
254+
passed: summary.passed,
237255
fixed: 0,
238-
warnings: warnCount,
239-
issues: failCount
256+
warnings: summary.warnings,
257+
issues: summary.issues
240258
)
241259

242260
// Phase 2: Confirm and execute pending fixes (after summary)
@@ -247,6 +265,8 @@ struct DoctorRunner {
247265
output.success("Applied \(fixedCount) fix\(fixedCount == 1 ? "" : "es").")
248266
}
249267
}
268+
269+
return summary
250270
}
251271

252272
// MARK: - Scope resolution
@@ -605,7 +625,7 @@ struct DoctorRunner {
605625
}
606626

607627
private mutating func docWarn(_ name: String, _ msg: String) {
608-
warnCount += 1
628+
// warningCounter increments inside output.warn — single source of truth.
609629
output.warn("\(name): \(msg)")
610630
}
611631

Tests/MCSTests/DoctorRunnerIntegrationTests.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,106 @@ struct DoctorRunnerIntegrationTests {
357357
try runner.run()
358358
}
359359
}
360+
361+
// MARK: - Summary Warning-Count Tests
362+
363+
/// Regression coverage for the doctor summary warning tally. The count must
364+
/// include warnings emitted *outside* the check loop (collision renames,
365+
/// unregistered packs), which previously printed but were never counted.
366+
///
367+
/// Assertions are deltas: ambient checks (e.g. ProjectIndexCheck) may add
368+
/// warnings of their own, so each test compares two otherwise-identical runs
369+
/// that differ only by the single side-channel warning under test.
370+
struct DoctorSummaryWarningCountTests {
371+
/// The injected counter is shared across CLIOutput copies, so a warning
372+
/// emitted through any copy (e.g. the one handed to the collision resolver)
373+
/// is tallied. This is the mechanism the doctor summary relies on.
374+
@Test("WarningCounter is shared across CLIOutput value copies")
375+
func warningCounterSharedAcrossCopies() {
376+
let counter = WarningCounter()
377+
let output = CLIOutput(colorsEnabled: false, warningCounter: counter)
378+
let copy = output // value-type copy, same counter instance
379+
380+
#expect(counter.count == 0)
381+
output.warn("first")
382+
copy.warn("second")
383+
#expect(counter.count == 2)
384+
}
385+
386+
@Test("Skill colliding with a pre-existing unmanaged file is counted in the summary")
387+
func collisionWarningCounted() throws {
388+
let (home, project) = try makeSandboxProject(label: "warncount-collision")
389+
defer { try? FileManager.default.removeItem(at: home) }
390+
391+
// A pack whose skill targets destination "my-skill".
392+
let skillSource = home.appendingPathComponent("pack-my-skill")
393+
try FileManager.default.createDirectory(at: skillSource, withIntermediateDirectories: true)
394+
try "managed".write(
395+
to: skillSource.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8
396+
)
397+
let component = ComponentDefinition(
398+
id: "test-pack.my-skill",
399+
displayName: "my-skill",
400+
description: "Skill",
401+
type: .skill,
402+
packIdentifier: "test-pack",
403+
dependencies: [],
404+
isRequired: true,
405+
installAction: .copyPackFile(source: skillSource, destination: "my-skill", fileType: .skill)
406+
)
407+
let pack = MockTechPack(
408+
identifier: "test-pack", displayName: "Test Pack", components: [component]
409+
)
410+
let registry = TechPackRegistry(packs: [pack])
411+
412+
// Configure the pack (no artifacts → nothing tracked at the destination).
413+
var state = try ProjectState(projectRoot: project)
414+
state.recordPack("test-pack")
415+
try state.save()
416+
417+
// Baseline: no pre-existing file at the destination → no collision.
418+
var baselineRunner = makeRunner(home: home, projectRoot: project, registry: registry)
419+
let baseline = try baselineRunner.run()
420+
421+
// Now plant a pre-existing UNMANAGED skill at the same destination.
422+
let existingSkill = project.appendingPathComponent(".claude/skills/my-skill")
423+
try FileManager.default.createDirectory(at: existingSkill, withIntermediateDirectories: true)
424+
try "user content".write(
425+
to: existingSkill.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8
426+
)
427+
428+
var collisionRunner = makeRunner(home: home, projectRoot: project, registry: registry)
429+
let withCollision = try collisionRunner.run()
430+
431+
// The only difference between the two runs is the collision warning.
432+
#expect(withCollision.warnings == baseline.warnings + 1)
433+
}
434+
435+
@Test("Unregistered --pack filter warning is counted in the summary")
436+
func unregisteredPackWarningCounted() throws {
437+
let (home, project) = try makeSandboxProject(label: "warncount-unregistered")
438+
defer { try? FileManager.default.removeItem(at: home) }
439+
440+
let pack = MockTechPack(identifier: "test-pack", displayName: "Test Pack")
441+
let registry = TechPackRegistry(packs: [pack])
442+
443+
var state = try ProjectState(projectRoot: project)
444+
state.recordPack("test-pack")
445+
try state.save()
446+
447+
// Baseline: filter to the registered pack only.
448+
var baselineRunner = makeRunner(
449+
home: home, projectRoot: project, registry: registry, packFilter: "test-pack"
450+
)
451+
let baseline = try baselineRunner.run()
452+
453+
// Add an unregistered pack id to the filter — same checks, plus one
454+
// "not registered" advisory warning.
455+
var ghostRunner = makeRunner(
456+
home: home, projectRoot: project, registry: registry, packFilter: "test-pack,ghost-pack"
457+
)
458+
let withGhost = try ghostRunner.run()
459+
460+
#expect(withGhost.warnings == baseline.warnings + 1)
461+
}
462+
}

0 commit comments

Comments
 (0)