diff --git a/Sources/ContextCore/LocalObservabilityClient.swift b/Sources/ContextCore/LocalObservabilityClient.swift index 05ca044..9b4709c 100644 --- a/Sources/ContextCore/LocalObservabilityClient.swift +++ b/Sources/ContextCore/LocalObservabilityClient.swift @@ -44,7 +44,7 @@ public struct LocalObservabilityClient: Sendable { agents: AgentRuntime.allCases.map { runtime in switch runtime { case .codex: - AgentTelemetry(runtime: .codex, connected: true, source: "Local OTEL · Prometheus · Tempo", signals: signalResult.signals) + AgentTelemetry(runtime: .codex, connected: signalResult.signals.values.contains { $0.value != nil }, source: "Local OTEL · Prometheus · Tempo", signals: signalResult.signals) case .claude: claudeResult.agent default: @@ -58,42 +58,44 @@ public struct LocalObservabilityClient: Sendable { sourceSchema: "codex-native-otel+claude-code-metrics/prometheus-tempo-v1" ) } catch { - return .unavailable(reason: "Local telemetry error: \(error.localizedDescription)") + return .unavailable(reason: "Local telemetry error: \(Self.safeError(error))") } } private func loadAggregateSignalsOutcome() async -> SignalsOutcome { do { return SignalsOutcome(signals: try await loadAggregateSignals(), error: nil) } catch { - let reason = error.localizedDescription + let reason = Self.safeError(error) return SignalsOutcome(signals: unavailableSignals(reason: reason), error: reason) } } private func loadAggregateSignals() async throws -> [TelemetrySignal: TelemetryValue] { - async let contextTokens = queryScalar("sum(increase(codex_turn_token_usage_sum{token_type=\"total\"}[24h])) or vector(0)") - async let nativeToolCalls = queryScalar("sum(increase(codex_tool_call_total[24h])) or vector(0)") - async let apiRequests = queryScalar("sum(increase(codex_api_request_total[24h])) or vector(0)") - async let websocketRequests = queryScalar("sum(increase(codex_websocket_request_total[24h])) or vector(0)") - async let proxyRequests = queryScalar("sum(increase(codex_turn_network_proxy_total[24h])) or vector(0)") - let measuredNetworkCalls = try await apiRequests + websocketRequests + proxyRequests + async let tokens = sampleOutcome(expression: "sum(increase(codex_turn_token_usage_sum{token_type=\"total\"}[24h]))") + async let tools = sampleOutcome(expression: "sum(increase(codex_tool_call_total[24h]))") + async let api = sampleOutcome(expression: "sum(increase(codex_api_request_total[24h]))") + async let websocket = sampleOutcome(expression: "sum(increase(codex_websocket_request_total[24h]))") + async let proxy = sampleOutcome(expression: "sum(increase(codex_turn_network_proxy_total[24h]))") + let (tokenResult, toolResult, apiResult, websocketResult, proxyResult) = await (tokens, tools, api, websocket, proxy) + func metric(_ result: SamplesOutcome, unit: String, note: String) -> TelemetryValue { + let value = result.samples.first?.value + return TelemetryValue(value: value?.rounded(), unit: unit, quality: value == nil ? .unavailable : .derived, + note: result.error ?? (value == nil ? "No range samples returned; missing or one-sample series cannot establish zero activity." : note)) + } + let network = [apiResult, websocketResult, proxyResult] + let networkValues = network.compactMap { $0.samples.first?.value } + let completeNetwork = networkValues.count == network.count + let networkTotal = networkValues.reduce(0, +) return [ - .contextTokens: TelemetryValue( - value: try await contextTokens.rounded(), unit: "estimated tokens / 24h", quality: .derived, - note: "PromQL increase of the total-token counter. Short-lived one-sample series can be missed; components are shown separately." - ), - .toolCalls: TelemetryValue( - value: try await nativeToolCalls.rounded(), unit: "estimated invocations / 24h", quality: .derived, - note: "PromQL increase of Codex tool calls. Short-lived series can be missed; MCP calls can overlap." - ), - .networkCalls: TelemetryValue( - value: measuredNetworkCalls.rounded(), unit: "estimated logical events / 24h", quality: .derived, - note: "PromQL increase across API, websocket-request, and proxy events. Categories can overlap and short-lived series can be missed." - ), - .internetUsage: TelemetryValue( - value: nil, unit: "bytes", quality: .unavailable, - note: "The current OTLP stream does not measure bytes transferred." - ), + .contextTokens: metric(tokenResult, unit: "estimated tokens / 24h", + note: "PromQL increase of the total-token counter. Short-lived one-sample series can be missed; components are shown separately."), + .toolCalls: metric(toolResult, unit: "estimated invocations / 24h", + note: "PromQL increase of Codex tool calls. Short-lived series can be missed; MCP calls can overlap."), + .networkCalls: TelemetryValue(value: completeNetwork && networkTotal.isFinite ? networkTotal.rounded() : nil, + unit: "estimated logical events / 24h", quality: completeNetwork && networkTotal.isFinite ? .derived : .unavailable, + note: completeNetwork ? "PromQL increase across API, websocket-request, and proxy events. Categories can overlap." : "One or more logical-event series are unavailable; a complete total cannot be established."), + .internetUsage: TelemetryValue(value: nil, unit: "bytes", quality: .unavailable, + note: "The current OTLP stream does not measure bytes transferred."), ] } @@ -117,7 +119,7 @@ public struct LocalObservabilityClient: Sendable { sessions: try await sessions, costs: try await costs, presence: try await presence) } catch { - let reason = error.localizedDescription + let reason = Self.safeError(error) return ClaudeLoad(agent: adapter(for: .claude), sections: [], error: reason) } } @@ -239,7 +241,7 @@ public struct LocalObservabilityClient: Sendable { private func sampleOutcome(expression: String) async -> SamplesOutcome { do { return SamplesOutcome(samples: try await querySamples(expression), error: nil) } - catch { return SamplesOutcome(samples: [], error: error.localizedDescription) } + catch { return SamplesOutcome(samples: [], error: Self.safeError(error)) } } private func items(_ outcome: SamplesOutcome?, label: String, unit: String, @@ -283,7 +285,7 @@ public struct LocalObservabilityClient: Sendable { URLQueryItem(name: "end", value: String(Int(now.timeIntervalSince1970))), ] let (data, response) = try await session.data(from: components.url!) - try requireSuccess(response: response, data: data, source: "Tempo session search") + try requireSuccess(response: response, source: "Tempo session search") let payload = try JSONDecoder().decode(TempoSearchResponse.self, from: data) let runs = payload.traces.compactMap { trace -> OTelRun? in guard let nanos = Double(trace.startTimeUnixNano) else { return nil } @@ -298,7 +300,7 @@ public struct LocalObservabilityClient: Sendable { }.sorted { $0.startedAt > $1.startedAt } return RunsOutcome(runs: runs, error: nil) } catch { - return RunsOutcome(runs: [], error: "Tempo recent sessions unavailable: \(error.localizedDescription)") + return RunsOutcome(runs: [], error: "Tempo recent sessions unavailable: \(Self.safeError(error))") } } @@ -314,22 +316,32 @@ public struct LocalObservabilityClient: Sendable { )! components.queryItems = [URLQueryItem(name: "query", value: expression)] let (data, response) = try await session.data(from: components.url!) - try requireSuccess(response: response, data: data, source: "Prometheus query") + try requireSuccess(response: response, source: "Prometheus query") let payload = try JSONDecoder().decode(PrometheusResponse.self, from: data) guard payload.status == "success" else { throw URLError(.cannotParseResponse) } - return payload.data.result.compactMap { result in - Double(result.rawValue).map { PrometheusSample(metric: result.metric, value: $0) } + return try payload.data.result.map { result in + guard let value = Double(result.rawValue), value.isFinite, value >= 0 else { + throw LocalObservabilityError.invalidSample + } + return PrometheusSample(metric: result.metric, value: value) } } - private func requireSuccess(response: URLResponse, data: Data, source: String) throws { + private func requireSuccess(response: URLResponse, source: String) throws { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - let body = String(data: data, encoding: .utf8)?.prefix(180) ?? "" - throw LocalObservabilityError.queryRejected(status: status, source: source, body: String(body)) + throw LocalObservabilityError.queryRejected(status: status, source: source) } } + // Never forward provider bodies, URLs, decoding debug descriptions or userInfo. + private static func safeError(_ error: Error) -> String { + if let error = error as? LocalObservabilityError { return error.localizedDescription } + if let error = error as? URLError { return "Telemetry transport failed (code \(error.code.rawValue))." } + if error is DecodingError { return "Telemetry response could not be decoded." } + return "Telemetry request failed. Error content was omitted." + } + private func adapter(for runtime: AgentRuntime) -> AgentTelemetry { let supported = runtime == .codex || runtime == .claude || runtime == .grok let note = supported @@ -378,12 +390,13 @@ struct PrometheusSample: Sendable, Equatable { } enum LocalObservabilityError: LocalizedError { - case queryRejected(status: Int, source: String, body: String) + case queryRejected(status: Int, source: String) + case invalidSample var errorDescription: String? { switch self { - case let .queryRejected(status, source, body): - "\(source) returned HTTP \(status): \(body)" + case let .queryRejected(status, source): "\(source) returned HTTP \(status). Response content was omitted." + case .invalidSample: "Telemetry returned an invalid numeric sample." } } } diff --git a/Sources/ContextCore/PluginInventory.swift b/Sources/ContextCore/PluginInventory.swift index 28996ce..98eb5fe 100644 --- a/Sources/ContextCore/PluginInventory.swift +++ b/Sources/ContextCore/PluginInventory.swift @@ -63,7 +63,7 @@ public struct PluginInventoryEntry: Sendable, Equatable, Identifiable { return preferences[0].enabled ? "Enabled in checked settings" : "Disabled in checked settings" } public var reviewReason: String { - if !unreferencedVersions.isEmpty { return "\(unreferencedVersions.count) versions not referenced by the checked registry" } + if !unreferencedVersions.isEmpty { return "\(unreferencedVersions.count) \(unreferencedVersions.count == 1 ? "version" : "versions") not referenced by the checked registry" } if versions.count > 1 { return "\(versions.count) cached versions; current installation needs verification" } if !localMatches.isEmpty { return "Matching instructions also exist in your local library" } if preferences.isEmpty { return "Check whether this plugin is enabled" } diff --git a/Sources/ContextCore/SkillPolicyModels.swift b/Sources/ContextCore/SkillPolicyModels.swift index d156e8d..3d8ce4b 100644 --- a/Sources/ContextCore/SkillPolicyModels.swift +++ b/Sources/ContextCore/SkillPolicyModels.swift @@ -38,6 +38,13 @@ public struct SkillExposure: Identifiable, Codable, Sendable, Equatable { public var id: String { logicalPath } } +public struct SkillPrecedence: Codable, Sendable, Equatable { + public enum State: String, Codable, Sendable { case preferred, shadowed, coexisting, unverified } + public let state: State + public let preferredDefinitionID: String? + public let source: String +} + public struct SkillRuntimePolicy: Identifiable, Codable, Sendable, Equatable { public let runtime: AgentRuntime public let mode: InvocationMode @@ -46,8 +53,10 @@ public struct SkillRuntimePolicy: Identifiable, Codable, Sendable, Equatable { public let invocation: String public let isExposed: Bool public let desiredMode: InvocationMode? + /// Resolved only among discovered routes; this never proves installed activation. + public let precedence: SkillPrecedence? - public init(runtime: AgentRuntime, mode: InvocationMode, explicit: Bool, reason: String, invocation: String, isExposed: Bool = true, desiredMode: InvocationMode? = nil) { + public init(runtime: AgentRuntime, mode: InvocationMode, explicit: Bool, reason: String, invocation: String, isExposed: Bool = true, desiredMode: InvocationMode? = nil, precedence: SkillPrecedence? = nil) { self.runtime = runtime self.mode = mode self.explicit = explicit @@ -55,10 +64,11 @@ public struct SkillRuntimePolicy: Identifiable, Codable, Sendable, Equatable { self.invocation = invocation self.isExposed = isExposed self.desiredMode = desiredMode + self.precedence = precedence } public var id: String { runtime.rawValue } - public var evidence: EvidenceQuality { explicit ? .measured : .derived } + public var evidence: EvidenceQuality { mode == .unverified ? .unavailable : explicit ? .measured : .derived } } public struct SkillRecord: Identifiable, Codable, Sendable, Equatable { @@ -68,7 +78,7 @@ public struct SkillRecord: Identifiable, Codable, Sendable, Equatable { public let logicalBytes: Int64 public let modified: Date public let exposures: [SkillExposure] - public let policies: [SkillRuntimePolicy] + public var policies: [SkillRuntimePolicy] /// SHA-256 of the complete SKILL.md when it fit inside the bounded reader. /// A nil value means exact-copy analysis is unavailable, not that content differs. public let contentFingerprint: String? diff --git a/Sources/ContextCore/SkillPolicyResolver.swift b/Sources/ContextCore/SkillPolicyResolver.swift index 4bc9067..3d83fa9 100644 --- a/Sources/ContextCore/SkillPolicyResolver.swift +++ b/Sources/ContextCore/SkillPolicyResolver.swift @@ -15,6 +15,7 @@ public enum SkillPolicyResolver { var records = groups.compactMap { physicalPath, items in makeRecord(physicalPath: physicalPath, items: items) } + records = resolvePrecedence(records) let recordsByName = Dictionary(grouping: records, by: { $0.name.lowercased() }) records = records.map { record in var record = record @@ -24,10 +25,72 @@ public enum SkillPolicyResolver { } record.definitionConflictCount = max(1, overlapping.count) return record - }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + }.sorted { + let order = $0.name.localizedCaseInsensitiveCompare($1.name) + return order == .orderedSame ? $0.id < $1.id : order == .orderedAscending + } return SkillCatalogSnapshot(records: records, coverage: report.coverage, generatedAt: Date()) } + private static func resolvePrecedence(_ records: [SkillRecord]) -> [SkillRecord] { + let byName = Dictionary(grouping: records, by: { $0.name.lowercased() }) + return records.map { original in + var record = original + record.policies = original.policies.map { policy in + let peers = byName[record.name.lowercased(), default: []].filter { $0.policy(for: policy.runtime)?.isExposed == true } + guard policy.isExposed, peers.count > 1 else { return policy } + var state = SkillPrecedence.State.unverified + var preferred: String? + var source = "No qualified winner rule for this runtime or these routes." + var reason = "Same-name precedence is unverified; no winner was inferred from scan order." + // The inventory can contain legacy or malformed names even though + // the shared skill-name convention is lowercase. Never infer a + // winner across case-only variants of one apparent name. + if Set(peers.map(\.name)).count > 1 { + reason = "Case-only name collision is unverified; no winner was inferred." + } + // Scope is not enough: recognize only direct skill-directory routes, + // excluding nested/custom roots, plugin namespaces and renamed frontmatter. + func routes(_ item: SkillRecord) -> [SkillExposure] { + item.exposures.filter { $0.provider == .claude && $0.applicability != .installedOnly } + } + func ordinary(_ item: SkillRecord) -> Bool { + let exposures = routes(item) + return !exposures.isEmpty && exposures.allSatisfy { + $0.logicalPath.hasSuffix("/.claude/skills/\(item.name)/SKILL.md") && + (($0.scope == .global && $0.source == "Claude · Personal skills") || $0.scope == .project) + } + } + if Set(peers.map(\.name)).count == 1, policy.runtime == .claude, peers.allSatisfy(ordinary) { + let personal = peers.filter { routes($0).contains { $0.scope == .global } } + if personal.count == 1, let winner = personal.first { + preferred = winner.id + state = record.id == winner.id ? .preferred : .shadowed + source = "https://code.claude.com/docs/en/skills#resolve-skills-that-share-a-name" + reason = state == .shadowed + ? "Shadowed among discovered Claude routes by personal definition \(winner.id). Personal skills override project skills." + : "Preferred among discovered Claude routes: personal skills override project skills." + reason += " Enterprise, synced skills and session overrides were not qualified; runtime activation remains unverified." + } + } else if Set(peers.map(\.name)).count == 1, policy.runtime == .codex, peers.allSatisfy({ item in + item.exposures.filter { [.codex, .agents].contains($0.provider) && $0.applicability != .installedOnly } + .allSatisfy { $0.logicalPath.contains("/.agents/skills/") } + }) { + state = .coexisting + source = "https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills" + reason = "Codex can list both same-name skills; these discovered routes have no exclusive winner. Runtime activation remains unverified." + } + return SkillRuntimePolicy(runtime: policy.runtime, + mode: state == .shadowed || state == .unverified ? .unverified : policy.mode, + explicit: state == .shadowed || state == .unverified ? false : policy.explicit, + reason: policy.reason + " " + reason, invocation: policy.invocation, + isExposed: policy.isExposed, desiredMode: policy.desiredMode, + precedence: SkillPrecedence(state: state, preferredDefinitionID: preferred, source: source)) + } + return record + } + } + private static func makeRecord(physicalPath: String, items: [AIContextItem]) -> SkillRecord? { guard let first = items.sorted(by: { $0.path < $1.path }).first else { return nil } let document = readDocument(at: URL(fileURLWithPath: physicalPath)) diff --git a/Sources/ContextDaddy/PluginInventoryView.swift b/Sources/ContextDaddy/PluginInventoryView.swift index 48aa5e5..859e49e 100644 --- a/Sources/ContextDaddy/PluginInventoryView.swift +++ b/Sources/ContextDaddy/PluginInventoryView.swift @@ -20,7 +20,11 @@ struct PluginInventoryView: View { @State private var revision = 0 private let fixture: PluginInventorySnapshot? - init(snapshot: PluginInventorySnapshot? = nil) { fixture = snapshot; _snapshot = State(initialValue: snapshot) } + init(snapshot: PluginInventorySnapshot? = nil, defaults: UserDefaults? = nil) { + fixture = snapshot + _snapshot = State(initialValue: snapshot) + _folder = AppStorage(wrappedValue: "", "skillWorkingFolder", store: defaults) + } private var matches: [PluginInventoryEntry] { (snapshot?.entries ?? []).filter { (owner == nil || $0.owner == owner) && @@ -122,9 +126,9 @@ struct PluginInventoryView: View { } private func summary(_ snapshot: PluginInventorySnapshot) -> some View { VStack(alignment: .leading, spacing: 8) { - Text("\(snapshot.entries.count) plugins found · \(snapshot.versionCount) cached versions") + Text(PluginCountCopy.summary(snapshot)) .font(.headline) - Text("\(snapshot.repeatedPluginCount) plugins have multiple versions · \(snapshot.unreferencedVersionCount) versions are not referenced by the checked Claude registry") + Text(PluginCountCopy.references(snapshot)) .font(.callout).foregroundStyle(DaddyTheme.muted) Text("Unreferenced does not mean safe to delete. Plugin managers own installation and removal.") .font(.caption).foregroundStyle(DaddyTheme.amber) @@ -150,7 +154,7 @@ struct PluginInventoryView: View { private var ledger: some View { Panel(padding: 14) { VStack(alignment: .leading, spacing: 0) { - Text("\(matches.count) plugins · grouped by owner and marketplace").font(.caption).foregroundStyle(DaddyTheme.muted).padding(.bottom, 12) + Text(PluginCountCopy.ledger(matches.count)).font(.caption).foregroundStyle(DaddyTheme.muted).padding(.bottom, 12) ForEach(visible) { entry in Button { selected = entry.id @@ -159,10 +163,10 @@ struct PluginInventoryView: View { HStack(alignment: .top) { Text(entry.name).font(.headline).foregroundStyle(.primary) Spacer(minLength: 8) - Text("\(entry.versions.count) versions").font(.caption).foregroundStyle(DaddyTheme.mint) + Text(PluginCountCopy.count(entry.versions.count, "version")).font(.caption).foregroundStyle(DaddyTheme.mint) } Text("\(entry.owner.rawValue) · \(entry.marketplace)").font(.caption).foregroundStyle(DaddyTheme.muted) - Text("\(entry.skillNames.count) skill names · \(size(entry.bytes, complete: entry.sizeComplete))") + Text("\(PluginCountCopy.count(entry.skillNames.count, "skill name")) · \(size(entry.bytes, complete: entry.sizeComplete))") .font(.caption).foregroundStyle(DaddyTheme.muted) Text(entry.settingLabel).font(.caption).foregroundStyle(entry.preferences.isEmpty ? DaddyTheme.amber : DaddyTheme.muted) }.padding(12).frame(maxWidth: .infinity, alignment: .leading) @@ -213,7 +217,7 @@ struct PluginInventoryView: View { ForEach(entry.versions) { version in Text(version.version).font(.headline.monospaced()) Text(versionState(version, entry: entry)).font(.caption).foregroundStyle(DaddyTheme.amber) - Text("\(size(version.bytes, complete: version.sizeComplete)) · \(version.skillNames.count) skill files").font(.caption) + Text("\(size(version.bytes, complete: version.sizeComplete)) · \(PluginCountCopy.count(version.skillNames.count, "skill file"))").font(.caption) if let date = version.modified { Text("Folder modified \(date.formatted(date: .abbreviated, time: .omitted)) · not last use").font(.caption).foregroundStyle(DaddyTheme.muted) } path(version.path) Divider() @@ -222,7 +226,7 @@ struct PluginInventoryView: View { } DisclosureGroup("Included capabilities") { VStack(alignment: .leading, spacing: 10) { - Text("\(entry.skillNames.count) unique skill directory names across cached versions. Files and declared components do not prove runtime activation.").font(.caption).foregroundStyle(DaddyTheme.muted) + Text(PluginCountCopy.capabilities(entry.skillNames.count)).font(.caption).foregroundStyle(DaddyTheme.muted) ForEach(entry.skillNames, id: \.self) { Text($0).font(.callout) } let components = Array(Set(entry.versions.flatMap(\.components))).sorted() if !components.isEmpty { Text("Also found: " + components.joined(separator: ", ")).font(.callout) } @@ -276,3 +280,22 @@ struct PluginInventoryView: View { loading = false } } + +// Shared by the actual view and fixture assertions; no layout or state behavior. +enum PluginCountCopy { + static func count(_ value: Int, _ singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } + static func summary(_ snapshot: PluginInventorySnapshot) -> String { + "\(count(snapshot.entries.count, "plugin")) found · \(snapshot.versionCount) cached \(snapshot.versionCount == 1 ? "version" : "versions")" + } + static func references(_ snapshot: PluginInventorySnapshot) -> String { + "\(snapshot.repeatedPluginCount) \(snapshot.repeatedPluginCount == 1 ? "plugin has" : "plugins have") multiple versions · \(snapshot.unreferencedVersionCount) \(snapshot.unreferencedVersionCount == 1 ? "version is" : "versions are") not referenced by the checked Claude registry" + } + static func ledger(_ count: Int) -> String { + "\(self.count(count, "plugin")) · grouped by owner and marketplace" + } + static func capabilities(_ count: Int) -> String { + "\(count) unique skill directory \(count == 1 ? "name" : "names") across cached versions. Files and declared components do not prove runtime activation." + } +} diff --git a/Tests/ContextCoreTests/SkillPrecedenceTests.swift b/Tests/ContextCoreTests/SkillPrecedenceTests.swift new file mode 100644 index 0000000..3e1ca85 --- /dev/null +++ b/Tests/ContextCoreTests/SkillPrecedenceTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import ContextCore + +struct SkillPrecedenceTests { + @Test func claudePersonalShadowsProjectIndependentOfInputOrderAndPreservesAliases() throws { + let home = try fixture() + let personal = try skill(home, ".claude/skills/shared") + let project = try skill(home, "work/.claude/skills/shared") + let alias = home.appendingPathComponent("work/.claude/skills/alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: personal.deletingLastPathComponent()) + let namedAlias = home.appendingPathComponent("alias-work/.claude/skills/shared") + try FileManager.default.createDirectory(at: namedAlias.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: namedAlias, withDestinationURL: personal.deletingLastPathComponent()) + let report = try AIContextDiscovery.discover(configuration: .init(home: home, projectRoots: [home.appendingPathComponent("work"), home.appendingPathComponent("alias-work")])) + let forward = SkillPolicyResolver.resolve(report: report) + let reversed = SkillPolicyResolver.resolve(report: AIContextDiscoveryReport(items: report.items.reversed(), folderRankings: report.folderRankings, coverage: report.coverage, elapsed: report.elapsed)) + let winner = try #require(forward.records.first { $0.id == personal.path }) + let loser = try #require(forward.records.first { $0.id == project.path }) + // An alias with a different directory name is deliberately unqualified. + #expect(winner.exposures.count == 3) + #expect(winner.policy(for: .claude)?.precedence?.state == .unverified) + #expect(loser.policy(for: .claude)?.mode == .unverified) + #expect(forward.records == reversed.records) + + let directItems = report.items.filter { !$0.path.contains("/alias/") } + let direct = SkillPolicyResolver.resolve(report: AIContextDiscoveryReport(items: directItems, folderRankings: [], coverage: report.coverage, elapsed: 0)) + let preferred = try #require(direct.records.first { $0.id == personal.path }?.policy(for: .claude)) + let shadowed = try #require(direct.records.first { $0.id == project.path }?.policy(for: .claude)) + #expect(preferred.precedence?.state == .preferred) + #expect(direct.records.first { $0.id == personal.path }?.exposures.count == 2) + #expect(shadowed.precedence?.state == .shadowed) + #expect(shadowed.precedence?.preferredDefinitionID == personal.path) + #expect(shadowed.mode == .unverified) + #expect(shadowed.evidence == .unavailable) + #expect(shadowed.reason.contains("Shadowed")) + #expect(shadowed.isExposed) // Physical route remains discoverable; not an active winner. + #expect(direct.governance(for: .claude).automaticCount == 1) + } + + @Test func codexDuplicatesCoexistAndUnknownVendorNeverGetsAnInventedWinner() throws { + let home = try fixture() + _ = try skill(home, ".agents/skills/shared") + _ = try skill(home, "work/.agents/skills/shared") + _ = try skill(home, ".cursor/skills/shared") + _ = try skill(home, "work/.cursor/skills/shared") + let records = SkillPolicyResolver.resolve(report: try AIContextDiscovery.discover(configuration: .init(home: home, projectRoots: [home.appendingPathComponent("work")]))).records + let codex = records.compactMap { $0.policy(for: .codex) }.filter(\.isExposed) + let cursor = records.compactMap { $0.policy(for: .cursor) }.filter(\.isExposed) + #expect(codex.count == 2) + #expect(codex.allSatisfy { $0.precedence?.state == .coexisting && $0.precedence?.preferredDefinitionID == nil }) + #expect(cursor.count == 2) + #expect(cursor.allSatisfy { $0.mode == .unverified && $0.precedence?.preferredDefinitionID == nil }) + } + + @Test func projectOnlyCollisionsRemainUnverifiedAndLegacyPoliciesStillDecode() throws { + let home = try fixture() + _ = try skill(home, "one/.claude/skills/shared") + _ = try skill(home, "two/.claude/skills/shared") + let records = SkillPolicyResolver.resolve(report: try AIContextDiscovery.discover(configuration: .init(home: home, projectRoots: [home.appendingPathComponent("one"), home.appendingPathComponent("two")]))).records + #expect(records.allSatisfy { $0.policy(for: .claude)?.precedence?.state == .unverified }) + let old = Data(#"{"runtime":"Claude","mode":"Manual only","explicit":true,"reason":"Fixture","invocation":"/shared","isExposed":true}"#.utf8) + let policy = try JSONDecoder().decode(SkillRuntimePolicy.self, from: old) + #expect(policy.precedence == nil) + #expect(policy.mode == .manualOnly) + } + + @Test func caseOnlyNameCollisionsRemainUnverifiedIndependentOfInputOrder() throws { + let home = try fixture() + let personal = try skill(home, ".claude/skills/Shared", name: "Shared") + let project = try skill(home, "work/.claude/skills/shared", name: "shared") + let report = try AIContextDiscovery.discover(configuration: .init(home: home, projectRoots: [home.appendingPathComponent("work")])) + let forward = SkillPolicyResolver.resolve(report: report) + let reversed = SkillPolicyResolver.resolve(report: AIContextDiscoveryReport(items: report.items.reversed(), folderRankings: report.folderRankings, coverage: report.coverage, elapsed: report.elapsed)) + let records = [try #require(forward.records.first { $0.id == personal.path }), try #require(forward.records.first { $0.id == project.path })] + #expect(records.allSatisfy { $0.definitionConflictCount == 2 }) + #expect(records.allSatisfy { $0.policy(for: .claude)?.mode == .unverified }) + #expect(records.allSatisfy { $0.policy(for: .claude)?.precedence?.state == .unverified }) + #expect(records.allSatisfy { $0.policy(for: .claude)?.precedence?.preferredDefinitionID == nil }) + #expect(forward.records == reversed.records) + } + + private func fixture() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ContextDaddy-precedence-" + UUID().uuidString).resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + private func skill(_ root: URL, _ relative: String, name: String = "shared") throws -> URL { + let file = root.appendingPathComponent(relative + "/SKILL.md") + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try "---\nname: \(name)\ndescription: Synthetic precedence fixture\n---\n".write(to: file, atomically: true, encoding: .utf8) + return file + } +} diff --git a/Tests/ContextCoreTests/TelemetryFailurePathTests.swift b/Tests/ContextCoreTests/TelemetryFailurePathTests.swift new file mode 100644 index 0000000..17b8c2e --- /dev/null +++ b/Tests/ContextCoreTests/TelemetryFailurePathTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import ContextCore + +struct TelemetryFailurePathTests { + private func load(_ scenario: String) async -> ObservabilitySnapshot { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [TelemetryFixtureProtocol.self] + configuration.urlCredentialStorage = nil + configuration.httpCookieStorage = nil + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + return await LocalObservabilityClient(grafanaBaseURL: URL(string: "https://\(scenario).invalid")!, session: session).load() + } + + @Test(arguments: ["http401", "http500", "transport", "malformed"]) + func errorsNeverRetainResponseOrTransportContent(_ scenario: String) async throws { + let snapshot = await load(scenario) + let encoded = try JSONEncoder().encode(snapshot) + let text = try #require(String(data: encoded, encoding: .utf8)) + #expect(!text.contains(TelemetryFixtureProtocol.canary)) + #expect(snapshot.agents.first { $0.runtime == .codex }?.signals[.contextTokens]?.value == nil) + if scenario.hasPrefix("http") { + #expect(text.contains(scenario == "http401" ? "HTTP 401" : "HTTP 500")) + } + // Exercise actual persistence, using only a newly allocated synthetic directory. + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("ContextDaddy-redaction-" + UUID().uuidString) + let store = TelemetrySnapshotStore(directory: directory) + _ = try await store.append(snapshot) + let saved = try String(contentsOf: directory.appendingPathComponent("telemetry-snapshots.json"), encoding: .utf8) + #expect(!saved.contains(TelemetryFixtureProtocol.canary)) + } + + @Test func missingSeriesAreUnavailableWhileObservedZeroIsDerived() async throws { + let absent = await load("missing") + let zero = await load("zero") + let missingAgent = try #require(absent.agents.first { $0.runtime == .codex }) + let zeroAgent = try #require(zero.agents.first { $0.runtime == .codex }) + #expect(!missingAgent.connected) + for signal in [TelemetrySignal.contextTokens, .toolCalls, .networkCalls] { + #expect(missingAgent.signals[signal]?.value == nil) + #expect(missingAgent.signals[signal]?.quality == .unavailable) + #expect(zeroAgent.signals[signal]?.value == 0) + #expect(zeroAgent.signals[signal]?.quality == .derived) + } + #expect(zeroAgent.connected) + } + + @Test(arguments: ["partial", "nonfinite", "invalid"]) + func oneBadSeriesDoesNotEraseGoodSignalsOrInventNetworkTotal(_ scenario: String) async throws { + let snapshot = await load(scenario) + let agent = try #require(snapshot.agents.first { $0.runtime == .codex }) + #expect(agent.signals[.contextTokens]?.value == 42) + #expect(agent.signals[.toolCalls]?.value == 7) + #expect(agent.signals[.networkCalls]?.value == nil) + #expect(agent.signals[.networkCalls]?.quality == .unavailable) + #expect(agent.signals[.internetUsage]?.value == nil) + _ = try JSONEncoder().encode(snapshot) + } +} + +/// Claims every request, including unexpected ones: no external/loopback fallback. +private final class TelemetryFixtureProtocol: URLProtocol, @unchecked Sendable { + static let canary = "synthetic-secret-prompt-error-canary" + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let url = request.url, request.httpMethod == "GET", let scenario = url.host?.split(separator: ".").first else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)); return + } + var status = 200 + var body = "{}" + if scenario == "transport" { + client?.urlProtocol(self, didFailWithError: NSError(domain: NSURLErrorDomain, code: -1001, + userInfo: [NSLocalizedDescriptionKey: Self.canary, NSURLErrorFailingURLStringErrorKey: Self.canary])) + return + } + if url.path == "/api/health" { body = "{}" } + else if scenario == "http401" || scenario == "http500" { + status = scenario == "http401" ? 401 : 500; body = Self.canary + } else if scenario == "malformed" { body = "{\"\(Self.canary)\":" } + else if url.path.hasSuffix("/api/search") { body = "{\"traces\":[]}" } + else if url.path.hasSuffix("/api/v1/query") { + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "query" }?.value ?? "" + // Regression guard: a synthetic zero fallback hides absent range series. + if query.contains("or vector(0)") { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)); return + } + var value: String? + if query.contains("codex_") { + switch scenario { + case "zero": value = "0" + case "partial", "nonfinite", "invalid": + if query.contains("codex_turn_token_usage_sum") { value = "42" } + else if query.contains("codex_tool_call_total") { value = "7" } + else if query.contains("codex_api_request_total") { value = "2" } + else if query.contains("codex_websocket_request_total") { value = "3" } + else if query.contains("codex_turn_network_proxy_total") { + value = scenario == "nonfinite" ? "NaN" : scenario == "invalid" ? "not-a-number" : nil + } + default: break + } + } + let result = value.map { "{\"metric\":{},\"value\":[1,\"\($0)\"]}" } ?? "" + body = "{\"status\":\"success\",\"data\":{\"result\":[\(result)]}}" + } else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)); return + } + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/Tests/ContextDaddyTests/PluginCountQualificationTests.swift b/Tests/ContextDaddyTests/PluginCountQualificationTests.swift new file mode 100644 index 0000000..47a6263 --- /dev/null +++ b/Tests/ContextDaddyTests/PluginCountQualificationTests.swift @@ -0,0 +1,94 @@ +import AppKit +import SwiftUI +import Testing +@testable import ContextCore +@testable import ContextDaddy + +@MainActor +@Suite(.serialized) +struct PluginCountQualificationTests { + @Test(arguments: [0, 1, 2]) func actualViewCopyUsesZeroOneMultipleSemantics(_ count: Int) { + let snapshot = fixture(count) + let expected = [ + "0 plugins found · 0 cached versions", + "1 plugin found · 1 cached version", + "2 plugins found · 4 cached versions" + ] + #expect(PluginCountCopy.summary(snapshot) == expected[count]) + #expect(PluginCountCopy.ledger(count) == "\(count) \(count == 1 ? "plugin" : "plugins") · grouped by owner and marketplace") + #expect(PluginCountCopy.count(count, "skill name") == ["0 skill names", "1 skill name", "2 skill names"][count]) + #expect(PluginCountCopy.count(count, "skill file") == ["0 skill files", "1 skill file", "2 skill files"][count]) + #expect(PluginCountCopy.capabilities(count).hasPrefix(["0 unique skill directory names", "1 unique skill directory name", "2 unique skill directory names"][count])) + #expect(PluginCountCopy.references(snapshot) == [ + "0 plugins have multiple versions · 0 versions are not referenced by the checked Claude registry", + "0 plugins have multiple versions · 1 version is not referenced by the checked Claude registry", + "2 plugins have multiple versions · 4 versions are not referenced by the checked Claude registry" + ][count]) + if count == 1 { #expect(snapshot.entries[0].reviewReason == "1 version not referenced by the checked registry") } + } + + @Test func oneRepeatedPluginAndZeroUnreferencedUseCorrectVerbs() { + let versions = fixture(2).entries[0].versions + let entry = PluginInventoryEntry(owner: .claude, marketplace: "fixture", name: "sample", summary: "Synthetic", + versions: versions, registrations: versions.map { .init(path: $0.path, scope: "user", project: nil) }, + registryVerified: true, preferences: [], localMatches: []) + let snapshot = PluginInventorySnapshot(entries: [entry], notes: [], checkedSources: [], generatedAt: .distantPast) + #expect(PluginCountCopy.references(snapshot) == "1 plugin has multiple versions · 0 versions are not referenced by the checked Claude registry") + } + + // Opt-in, offscreen only. No NSApplication activation, orderFront, standard + // preference writes, real discovery, plugin commands, or network access. + @Test(.enabled(if: ProcessInfo.processInfo.environment["CONTEXTDADDY_TEST_PLUGIN_RENDER"] == "1")) + func rendersSyntheticCountsWithoutGlobalPreferenceWrites() throws { + let directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("artifacts/queue-review/native-counts") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let defaults = try #require(UserDefaults(suiteName: "ContextDaddy-fixture-" + UUID().uuidString)) + let model = ContextDaddyModel(discover: { _ in throw CancellationError() }) + for count in [0, 1, 2] { + for width in [390, 768, 1440] { + let view = PluginInventoryView(snapshot: fixture(count), defaults: defaults) + .environment(model).preferredColorScheme(.dark).background(DaddyTheme.canvas) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: width, height: 1000) + let window = NSWindow(contentRect: host.frame, styleMask: [], backing: .buffered, defer: false) + window.contentView = host + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + host.layoutSubtreeIfNeeded(); window.displayIfNeeded() + let scrolls = scrollViews(host) + #expect(scrolls.count == 1) + let scroll = try #require(scrolls.first) + let document = try #require(scroll.documentView) + #expect(document.bounds.width <= scroll.contentView.bounds.width + 1) + let bottom = max(0, document.bounds.height - scroll.contentView.bounds.height) + if bottom > 0 { + scroll.contentView.scroll(to: NSPoint(x: 0, y: bottom)) + scroll.reflectScrolledClipView(scroll.contentView) + #expect(abs(scroll.contentView.bounds.maxY - document.bounds.maxY) < 2) + scroll.contentView.scroll(to: .zero) + scroll.reflectScrolledClipView(scroll.contentView) + } + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: directory.appendingPathComponent("plugins-count-\(count)-\(width).png")) + window.contentView = nil + } + } + } + + private func fixture(_ count: Int) -> PluginInventorySnapshot { + let entries = (0.. [NSScrollView] { + (view as? NSScrollView).map { [$0] } ?? view.subviews.flatMap(scrollViews) + } +}