From d5f5a62dafacc4520f8ad34bb06a96485b08a746 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Sep 2026 12:37:48 +0200 Subject: [PATCH 1/2] feat(cli): Add documentation type discovery List symbols from technology root DocC pages and expose stable table and JSON output. Resolve nested paths, map lookup failures to actionable links, and preserve raw responses without eager decoding. --- README.md | 17 ++ .../client/AppleDocumentationClient+DTO.swift | 14 ++ .../CLI/client/AppleDocumentationClient.swift | 218 ++++++++++++++++-- .../DocumentationTypeCatalogClient.swift | 11 + .../client/TypeDocumentationDocument.swift | 1 - Sources/CLI/cmd/types/TypesCommand.swift | 2 +- Sources/CLI/cmd/types/TypesListCommand.swift | 67 ++++++ .../cmd/types/TypesListCommandRunner.swift | 25 ++ .../cmd/types/TypesViewCommandRunner.swift | 2 +- Sources/CLI/main/AppleDocs.swift | 3 +- Sources/CLI/main/Dependencies.swift | 8 + ...DefaultDocumentationTypeListRenderer.swift | 45 ++++ .../DefaultTypeDocumentationRenderer.swift | 7 +- .../DocumentationTypeListRenderer.swift | 9 + .../TextTypeDocumentationRenderer.swift | 18 +- .../renderer/TypeDocumentationRenderer.swift | 2 +- Sources/CLI/skills/BundledAgentSkills.swift | 18 +- .../CLI/telemetry/SentryCommandContext.swift | 12 + .../CLI/telemetry/SentryConfiguration.swift | 9 + Sources/CLI/types/DocumentationType.swift | 6 + .../AppleDocsCommandIntegrationTests.swift | 46 ++++ .../AppleDocumentationClientErrorTests.swift | 110 +++++++++ .../AppleDocumentationClientTests.swift | 85 ++++--- ...ppleDocumentationClientTypeListTests.swift | 149 ++++++++++++ .../types/TypesListCommandRunnerTests.swift | 57 +++++ .../types/TypesViewCommandRunnerTests.swift | 9 +- .../cmd/types/TypesViewCommandTests.swift | 20 +- ...ltDocumentationTypeListRendererTests.swift | 62 +++++ ...efaultTypeDocumentationRendererTests.swift | 27 +-- ...eDocumentationRendererReferenceTests.swift | 57 +++++ .../telemetry/SentryCommandContextTests.swift | 25 ++ .../telemetry/SentryConfigurationTests.swift | 18 ++ docs/TELEMETRY.md | 4 +- 33 files changed, 1065 insertions(+), 98 deletions(-) create mode 100644 Sources/CLI/client/DocumentationTypeCatalogClient.swift create mode 100644 Sources/CLI/cmd/types/TypesListCommand.swift create mode 100644 Sources/CLI/cmd/types/TypesListCommandRunner.swift create mode 100644 Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift create mode 100644 Sources/CLI/renderer/DocumentationTypeListRenderer.swift create mode 100644 Sources/CLI/types/DocumentationType.swift create mode 100644 Tests/CLITests/client/AppleDocumentationClientErrorTests.swift create mode 100644 Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift create mode 100644 Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift create mode 100644 Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift create mode 100644 Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift diff --git a/README.md b/README.md index 54762f9..301834e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,23 @@ The terminal output includes available information such as: - Canonical Apple Developer URL Every invocation requires `--technology`. The CLI does not persist a selected framework or other session state. +Nested symbols accept either dotted Swift spelling or slash-separated DocC paths: + +```bash +apple-docs types view URLSession.AsyncBytes --technology Foundation +apple-docs types view URLSession/AsyncBytes --technology Foundation +``` + +## Type discovery + +List the API symbols referenced directly by a technology's root DocC page: + +```bash +apple-docs types list --technology MetricKit +apple-docs types list --technology MetricKit --json +``` + +Each result includes the symbol name, kind, command-ready DocC path, and canonical Apple Developer URL. Apple's root pages are curated, so large frameworks may link to collection pages instead of listing every API directly. ## Technologies diff --git a/Sources/CLI/client/AppleDocumentationClient+DTO.swift b/Sources/CLI/client/AppleDocumentationClient+DTO.swift index 2d89c0e..58574a1 100644 --- a/Sources/CLI/client/AppleDocumentationClient+DTO.swift +++ b/Sources/CLI/client/AppleDocumentationClient+DTO.swift @@ -15,6 +15,7 @@ struct DocumentationVariantDTO: Decodable, Sendable { } struct DocumentationTextDTO: Decodable, Sendable { + let code: String? let identifier: String? let text: String? } @@ -25,7 +26,20 @@ struct DocumentationBlockDTO: Decodable, Sendable { struct DocumentationReferenceDTO: Decodable, Sendable { let abstract: [DocumentationTextDTO]? + let fragments: [DocumentationFragmentDTO]? + let kind: String? + let role: String? let title: String? + let url: String? +} + +struct DocumentationFragmentDTO: Decodable, Sendable { + let kind: String + let text: String +} + +struct TechnologyDocumentationPageDTO: Decodable, Sendable { + let references: [String: DocumentationReferenceDTO] } struct DocumentationReferenceSectionDTO: Decodable, Sendable { diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index 5570c3f..b11b039 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -9,9 +9,26 @@ import Foundation #endif struct DefaultAppleDocumentationClient: Sendable { - enum Error: Swift.Error, Equatable, LocalizedError { + enum Error: Swift.Error, Equatable, LocalizedError, ExpectedCommandError { case httpStatus(Int) case invalidResponse + case technologyNotFound(String) + case typeNotFound( + name: String, + technology: String, + suggestion: DocumentationType?, + technologyURL: String + ) + case unsupportedTechnology(name: String, url: String) + + var isExpected: Bool { + switch self { + case .technologyNotFound, .typeNotFound, .unsupportedTechnology: + return true + case .httpStatus, .invalidResponse: + return false + } + } var errorDescription: String? { switch self { @@ -19,10 +36,49 @@ struct DefaultAppleDocumentationClient TypeDocumentationDocument { - let url = baseURL.appending(component: "documentation") - .appending(component: technology.lowercased()) - .appending(component: name.lowercased()) - .appendingPathExtension("json") - let (data, response) = try await dependencies.data(from: url) + do { + return TypeDocumentationDocument( + data: try await fetchData(from: typeURL(name: name, technology: technology)) + ) + } catch Error.httpStatus(404) { + let resolved = try await resolveTechnology(named: technology) + guard let slug = resolved.documentationSlug else { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } - guard let httpResponse = response as? HTTPURLResponse else { - throw Self.Error.invalidResponse - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw Self.Error.httpStatus(httpResponse.statusCode) + // Display names do not always match DocC path components, such as Apple CryptoKit. + if slug.caseInsensitiveCompare(technology) != .orderedSame { + do { + return TypeDocumentationDocument( + data: try await fetchData(from: typeURL(name: name, technology: slug)) + ) + } catch Error.httpStatus(404) { + // Continue with the canonical root so the error can offer useful discovery links. + } + } + + let types = try await fetchTypesDirect(technology: slug) + let normalizedName = normalizedSymbolName(name) + let suggestion = types.first { + normalizedSymbolName($0.name) == normalizedName + } + throw Error.typeNotFound( + name: name, + technology: resolved.name, + suggestion: suggestion, + technologyURL: resolved.url + ) } + } - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - return TypeDocumentationDocument(data: data, page: page) + func fetchTypes(technology: String) async throws -> [DocumentationType] { + do { + return try await fetchTypesDirect(technology: technology) + } catch Error.httpStatus(404) { + let resolved = try await resolveTechnology(named: technology) + guard let slug = resolved.documentationSlug else { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + guard slug.caseInsensitiveCompare(technology) != .orderedSame else { + throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + return try await fetchTypesDirect(technology: slug) + } } func fetchTechnologies() async throws -> [Technology] { let url = baseURL.appending(component: "documentation") .appending(component: "technologies") .appendingPathExtension("json") - let (data, response) = try await dependencies.data(from: url) + let data = try await fetchData(from: url) + let page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) + return page.sections.flatMap(\.groups).flatMap(\.technologies).map { + Technology(name: $0.title, identifier: $0.destination.identifier) + } + } + private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] { + let url = baseURL.appending(component: "documentation") + .appending(component: technology.lowercased()) + .appendingPathExtension("json") + let data = try await fetchData(from: url) + let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) + let pathPrefix = "/documentation/\(technology.lowercased())/" + + // Root pages also reference articles and neighboring frameworks. The symbol role and path + // prefix keep this command faithful to Apple's direct API listing for the requested technology. + return page.references.values.compactMap { reference in + guard + reference.kind == "symbol", + reference.role == "symbol", + let name = reference.title, + let referencePath = reference.url, + referencePath.lowercased().hasPrefix(pathPrefix) + else { + return nil + } + + let path = String(referencePath.dropFirst(pathPrefix.count)) + let kind = reference.fragments?.first { $0.kind == "keyword" }?.text ?? "symbol" + return DocumentationType( + name: name, + kind: kind, + path: path, + url: "https://developer.apple.com\(referencePath)" + ) + }.sorted { + let comparison = $0.name.compare($1.name, options: .caseInsensitive) + return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending + } + } + + private func fetchData(from url: URL) async throws -> Data { + let (data, response) = try await dependencies.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { - throw Self.Error.invalidResponse + throw Error.invalidResponse } guard (200..<300).contains(httpResponse.statusCode) else { - throw Self.Error.httpStatus(httpResponse.statusCode) + throw Error.httpStatus(httpResponse.statusCode) } + return data + } - let page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) - return page.sections.flatMap(\.groups).flatMap(\.technologies).map { - Technology(name: $0.title, identifier: $0.destination.identifier) + private func typeURL(name: String, technology: String) -> URL { + var url = baseURL.appending(component: "documentation") + .appending(component: technology.lowercased()) + // DocC uses path components for nested symbols while Swift spelling uses dots. + for component in name.replacingOccurrences(of: ".", with: "/").split(separator: "/") { + url.append(component: component.lowercased()) } + url.appendPathExtension("json") + return url + } + + private func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { + let technologies = try await fetchTechnologies() + guard + let technology = technologies.first(where: { + $0.name.caseInsensitiveCompare(requestedName) == .orderedSame + || documentationSlug(from: $0.identifier)?.caseInsensitiveCompare(requestedName) + == .orderedSame + }) + else { + throw Error.technologyNotFound(requestedName) + } + + return ResolvedTechnology( + name: technology.name, + documentationSlug: documentationSlug(from: technology.identifier), + url: publicURL(from: technology.identifier) + ) + } + + private func documentationSlug(from identifier: String) -> String? { + let marker = "/documentation/" + guard let range = identifier.range(of: marker) else { + return nil + } + let remainder = identifier[range.upperBound...] + guard !remainder.contains("/") else { + return nil + } + return String(remainder) + } + + private func publicURL(from identifier: String) -> String { + guard identifier.hasPrefix("doc://"), let pathStart = identifier.dropFirst(6).firstIndex(of: "/") else { + return identifier + } + return "https://developer.apple.com\(identifier[pathStart...].lowercased())" + } + + private func normalizedSymbolName(_ name: String) -> String { + name.lowercased().filter { $0.isLetter || $0.isNumber } } } diff --git a/Sources/CLI/client/DocumentationTypeCatalogClient.swift b/Sources/CLI/client/DocumentationTypeCatalogClient.swift new file mode 100644 index 0000000..15ac033 --- /dev/null +++ b/Sources/CLI/client/DocumentationTypeCatalogClient.swift @@ -0,0 +1,11 @@ +import Foundation + +#if DEBUG + protocol DocumentationTypeCatalogClient: Sendable { + func fetchTypes(technology: String) async throws -> [DocumentationType] + } + + extension DefaultAppleDocumentationClient: DocumentationTypeCatalogClient {} +#else + typealias DocumentationTypeCatalogClient = DefaultAppleDocumentationClient +#endif diff --git a/Sources/CLI/client/TypeDocumentationDocument.swift b/Sources/CLI/client/TypeDocumentationDocument.swift index acd0eb1..68eab9f 100644 --- a/Sources/CLI/client/TypeDocumentationDocument.swift +++ b/Sources/CLI/client/TypeDocumentationDocument.swift @@ -2,5 +2,4 @@ import Foundation struct TypeDocumentationDocument: Sendable { let data: Data - let page: TypeDocumentationPageDTO } diff --git a/Sources/CLI/cmd/types/TypesCommand.swift b/Sources/CLI/cmd/types/TypesCommand.swift index 2530475..24ef78a 100644 --- a/Sources/CLI/cmd/types/TypesCommand.swift +++ b/Sources/CLI/cmd/types/TypesCommand.swift @@ -4,6 +4,6 @@ struct TypesCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "types", abstract: "Work with Apple documentation types.", - subcommands: [TypesViewCommand.self] + subcommands: [TypesListCommand.self, TypesViewCommand.self] ) } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift new file mode 100644 index 0000000..cec5f0a --- /dev/null +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Logging +@preconcurrency import SentrySwift + +struct TypesListCommand: AsyncParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.types-list" + ) + + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List types in an Apple documentation technology." + ) + + @Option(help: "The framework or technology whose types to list.") + var technology: String + + @Flag(help: "Output a JSON array of types.") + var json = false + + mutating func run() async throws { + let context = SentryCommandContext.typesList( + technology: technology, + json: json + ) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) + } + + let result = try await TypesListCommandRunner( + client: Dependencies.documentationClient, + renderer: Dependencies.documentationTypeListRenderer(json: json) + ).run(technology: technology) + if SentrySDK.isEnabled { + SentrySDK.metrics.gauge( + key: "apple_docs.type.catalog.count", + value: Double(result.typeCount), + attributes: context.metricAttributes + ) + } + print(result.output) + } +} diff --git a/Sources/CLI/cmd/types/TypesListCommandRunner.swift b/Sources/CLI/cmd/types/TypesListCommandRunner.swift new file mode 100644 index 0000000..8a4fbde --- /dev/null +++ b/Sources/CLI/cmd/types/TypesListCommandRunner.swift @@ -0,0 +1,25 @@ +struct TypesListCommandRunner: Sendable { + struct Result: Sendable { + let output: String + let typeCount: Int + } + + private let client: DocumentationTypeCatalogClient + private let renderer: DocumentationTypeListRenderer + + init( + client: DocumentationTypeCatalogClient, + renderer: DocumentationTypeListRenderer + ) { + self.client = client + self.renderer = renderer + } + + func run(technology: String) async throws -> Result { + let types = try await client.fetchTypes(technology: technology) + return Result( + output: try renderer.render(types), + typeCount: types.count + ) + } +} diff --git a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift index 8f3b40b..ceffe68 100644 --- a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift @@ -21,7 +21,7 @@ struct TypesViewCommandRunner: Sendable { technology: technology ) return Result( - output: renderer.render(document), + output: try renderer.render(document), responseByteCount: document.data.count ) } diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index b3319bd..65cf406 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -41,7 +41,8 @@ enum AppleDocs { } } catch { if telemetryEnabled, let span = SentrySDK.span { - let expected = error is ValidationError + // Lookup misses are actionable CLI outcomes, not application reliability failures. + let expected = error is ValidationError || SentryConfiguration.isExpected(error: error) span.status = expected ? .invalidArgument : .internalError if expected { Self.logger.info("CLI command rejected") diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index d8e3cde..af8ea4c 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -14,6 +14,14 @@ enum Dependencies { ) } + static func documentationTypeListRenderer( + json: Bool + ) -> DefaultDocumentationTypeListRenderer { + DefaultDocumentationTypeListRenderer( + output: json ? .json : .table + ) + } + static func technologyListRenderer( json: Bool ) -> DefaultTechnologyListRenderer { diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift new file mode 100644 index 0000000..0cc8b6b --- /dev/null +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -0,0 +1,45 @@ +import Foundation + +struct DefaultDocumentationTypeListRenderer: Sendable { + enum Output: Sendable { + case table + case json + } + + private let output: Output + + init(output: Output) { + self.output = output + } + + func render(_ types: [DocumentationType]) throws -> String { + switch output { + case .table: + return renderTable(types) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + // JSONEncoder produces valid UTF-8, so preserve a non-optional rendering contract. + // swiftlint:disable:next optional_data_string_conversion + return String(decoding: try encoder.encode(types), as: UTF8.self) + } + } + + private func renderTable(_ types: [DocumentationType]) -> String { + let nameWidth = max("SYMBOL".count, types.map(\.name.count).max() ?? 0) + let kindWidth = max("KIND".count, types.map(\.kind.count).max() ?? 0) + let pathWidth = max("PATH".count, types.map(\.path.count).max() ?? 0) + + let heading = + "SYMBOL".padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " + + "KIND".padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " + + "PATH".padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " URL" + let rows = types.map { type in + type.name.padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " + + type.kind.padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " + + type.path.padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " " + + type.url + } + return ([heading] + rows).joined(separator: "\n") + } +} diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 8f5e058..71e20f6 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -1,3 +1,5 @@ +import Foundation + struct DefaultTypeDocumentationRenderer: Sendable { enum Output: Sendable { case text @@ -10,10 +12,11 @@ struct DefaultTypeDocumentationRenderer: Sendable { self.output = output } - func render(_ document: TypeDocumentationDocument) -> String { + func render(_ document: TypeDocumentationDocument) throws -> String { switch output { case .text: - return TextTypeDocumentationRenderer().render(document.page) + let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: document.data) + return TextTypeDocumentationRenderer().render(page) case .json: return RawJSONTypeDocumentationRenderer().render(document) } diff --git a/Sources/CLI/renderer/DocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift new file mode 100644 index 0000000..1899c1b --- /dev/null +++ b/Sources/CLI/renderer/DocumentationTypeListRenderer.swift @@ -0,0 +1,9 @@ +#if DEBUG + protocol DocumentationTypeListRenderer: Sendable { + func render(_ types: [DocumentationType]) throws -> String + } + + extension DefaultDocumentationTypeListRenderer: DocumentationTypeListRenderer {} +#else + typealias DocumentationTypeListRenderer = DefaultDocumentationTypeListRenderer +#endif diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift index e6e887e..4753560 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift @@ -77,10 +77,16 @@ struct TextTypeDocumentationRenderer: Sendable { } var item = " \(title)" - let abstract = reference.abstract?.compactMap(\.text).joined() ?? "" + let abstract = + reference.abstract.map { + inlineText($0, references: references) + } ?? "" if includesAbstract && !abstract.isEmpty { item += " — \(abstract)" } + if let url = reference.url { + item += "\n \(documentationURL(for: url))" + } return item } @@ -105,6 +111,13 @@ struct TextTypeDocumentationRenderer: Sendable { } } + private func documentationURL(for path: String) -> String { + if path.hasPrefix("http://") || path.hasPrefix("https://") { + return path + } + return "https://developer.apple.com\(path)" + } + private func inlineText( _ content: [DocumentationTextDTO], references: [String: DocumentationReferenceDTO] @@ -116,7 +129,8 @@ struct TextTypeDocumentationRenderer: Sendable { if let identifier = item.identifier { return references[identifier]?.title ?? identifier } - return "" + // DocC encodes inline symbol spelling such as AppIntent as codeVoice, not text. + return item.code ?? "" }.joined() } } diff --git a/Sources/CLI/renderer/TypeDocumentationRenderer.swift b/Sources/CLI/renderer/TypeDocumentationRenderer.swift index 65d0cb8..7015210 100644 --- a/Sources/CLI/renderer/TypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TypeDocumentationRenderer.swift @@ -1,6 +1,6 @@ #if DEBUG protocol TypeDocumentationRenderer: Sendable { - func render(_ document: TypeDocumentationDocument) -> String + func render(_ document: TypeDocumentationDocument) throws -> String } extension DefaultTypeDocumentationRenderer: TypeDocumentationRenderer {} diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index 2a01799..e735ebb 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -38,7 +38,23 @@ enum BundledAgentSkills { ``` The text output includes the type summary, declaration, availability, inheritance, conformances, documented - members, related APIs, and canonical Apple Developer URL. + members, related APIs, and canonical Apple Developer URL. Use a returned slash-separated path, or a dotted + Swift type name, to retrieve nested documentation: + + ```bash + apple-docs types view URLSession.AsyncBytes --technology Foundation + ``` + + ## Discover root types + + List the symbols referenced directly by a technology's root DocC page: + + ```bash + apple-docs types list --technology MetricKit + apple-docs types list --technology MetricKit --json + ``` + + Apple's root pages are curated and may link to collection pages instead of listing every API directly. ## Retrieve raw DocC JSON diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift index 067dedd..781818d 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -29,6 +29,18 @@ struct SentryCommandContext: Equatable, Sendable { ) } + static func typesList( + technology: String, + json: Bool + ) -> SentryCommandContext { + SentryCommandContext( + command: "types.list", + outputJSON: json, + technology: technology, + typeName: nil + ) + } + static func typesView( name: String, technology: String, diff --git a/Sources/CLI/telemetry/SentryConfiguration.swift b/Sources/CLI/telemetry/SentryConfiguration.swift index 3b1365a..ddc0f43 100644 --- a/Sources/CLI/telemetry/SentryConfiguration.swift +++ b/Sources/CLI/telemetry/SentryConfiguration.swift @@ -1,6 +1,10 @@ import Foundation @preconcurrency import SentrySwift +protocol ExpectedCommandError: Error { + var isExpected: Bool { get } +} + struct SentryConfiguration { private static let allowedBreadcrumbDataKeys: Set = [ "apple_docs.technology", @@ -43,6 +47,7 @@ struct SentryConfiguration { private static let allowedMetricNames: Set = [ "apple_docs.response.size", "apple_docs.technology.catalog.count", + "apple_docs.type.catalog.count", "apple_docs.technology.requested", "apple_docs.type.requested", ] @@ -54,6 +59,10 @@ struct SentryConfiguration { environment["TELEMETRY_DISABLED"]?.caseInsensitiveCompare("true") != .orderedSame } + static func isExpected(error: Swift.Error) -> Bool { + (error as? any ExpectedCommandError)?.isExpected == true + } + static func configure(_ options: Options) { options.dsn = dsn options.environment = BuildMetadata.environment diff --git a/Sources/CLI/types/DocumentationType.swift b/Sources/CLI/types/DocumentationType.swift new file mode 100644 index 0000000..9a117b5 --- /dev/null +++ b/Sources/CLI/types/DocumentationType.swift @@ -0,0 +1,6 @@ +struct DocumentationType: Codable, Equatable, Sendable { + let name: String + let kind: String + let path: String + let url: String +} diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index d0772b3..5268df1 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -53,6 +53,45 @@ struct AppleDocsCommandIntegrationTests { #expect(output.contains("Declaration\n\n @frozen struct String")) } + @Test("lists MetricKit root types as JSON") + func listsMetricKitTypes() throws { + // -- Arrange -- + let arguments = ["types", "list", "--technology", "MetricKit", "--json"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let types = try JSONDecoder().decode([ListedType].self, from: Data(output.utf8)) + + // -- Assert -- + #expect( + types.contains( + ListedType( + kind: "class", + name: "MetricManager", + path: "metricmanager", + url: "https://developer.apple.com/documentation/metrickit/metricmanager" + ) + ) + ) + } + + @Test("resolves a dotted nested type as JSON") + func resolvesDottedNestedType() throws { + // -- Arrange -- + let arguments = [ + "types", "view", "URLSession.AsyncBytes", + "--technology", "Foundation", + "--json", + ] + + // -- Act -- + let output = try runAppleDocs(arguments) + let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) + + // -- Assert -- + #expect(document.metadata.title == "URLSession.AsyncBytes") + } + @Test("lists stable technologies as JSON") func listsStableTechnologies() throws { // -- Arrange -- @@ -96,6 +135,13 @@ private struct TypeDocument: Decodable { } } +private struct ListedType: Decodable, Equatable { + let kind: String + let name: String + let path: String + let url: String +} + private struct Technology: Decodable, Equatable { let identifier: String let name: String diff --git a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift new file mode 100644 index 0000000..3ce6efa --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Apple documentation client errors") +struct AppleDocumentationClientErrorTests { + @Test("maps a missing type to discovery guidance") + func mapsMissingTypeToDiscoveryGuidance() async throws { + // -- Arrange -- + let typeURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata/model.json") + ) + let technologiesURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") + ) + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: LookupTestTransport( + responses: [ + typeURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: swiftDataCatalogData), + rootURL: .init(statusCode: 200, data: modelRootData), + ] + ) + ) + + // -- Act -- + do { + _ = try await client.fetchType(named: "Model", technology: "SwiftData") + Issue.record("Expected the request to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + No Apple documentation found for 'Model' in SwiftData. + + Did you mean: + Model() + https://developer.apple.com/documentation/swiftdata/model() + + Browse available types: + apple-docs types list --technology "SwiftData" + https://developer.apple.com/documentation/swiftdata + """ + ) + } + } +} + +private let swiftDataCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "doc://com.apple.documentation/documentation/SwiftData" + }, + "title": "SwiftData" + }] + }] + }] + } + """.utf8 +) + +private let modelRootData = Data( + """ + { + "references": { + "doc://model": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Model()", + "url": "/documentation/swiftdata/model()" + } + } + } + """.utf8 +) + +private struct LookupTestTransport: HTTPDataTransport { + struct Response: Sendable { + let statusCode: Int + let data: Data + } + + let responses: [URL: Response] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let result = responses[url] else { + throw LookupTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: result.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (result.data, response) + } +} + +private enum LookupTestError: Error { + case unexpectedURL(URL) +} diff --git a/Tests/CLITests/client/AppleDocumentationClientTests.swift b/Tests/CLITests/client/AppleDocumentationClientTests.swift index 2e61837..f378bcb 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTests.swift @@ -47,13 +47,10 @@ struct AppleDocumentationClientTests { ) #expect(document.data == data) - #expect(document.page.metadata.title == "MXHangDiagnostic") - #expect(document.page.metadata.modules.map(\.name) == ["MetricKit"]) - #expect(document.page.abstract.first?.text == "A diagnostic report.") } - @Test("decodes a page with an untitled image reference") - func decodesUntitledImageReference() async throws { + @Test("preserves a successful response that the text renderer cannot decode") + func preservesUndecodableResponse() async throws { // -- Arrange -- let expectedURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/string.json") @@ -66,11 +63,12 @@ struct AppleDocumentationClientTests { headerFields: ["Content-Type": "application/json"] ) ) + let data = Data("{\"newUpstreamShape\":true}".utf8) let client = DefaultAppleDocumentationClient( dependencies: TypePageTransport( expectedURL: expectedURL, response: response, - data: untitledImageReferencePageData + data: data ) ) @@ -78,8 +76,42 @@ struct AppleDocumentationClientTests { let document = try await client.fetchType(named: "String", technology: "Swift") // -- Assert -- - #expect(document.page.metadata.title == "String") - #expect(document.page.references.keys.contains("Swift-PageImage-card.png")) + #expect(document.data == data) + } + + @Test( + "resolves nested type names as documentation path components", + arguments: ["URLSession.AsyncBytes", "URLSession/AsyncBytes"] + ) + func resolvesNestedTypePath(name: String) async throws { + // -- Arrange -- + let expectedURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/foundation/urlsession/asyncbytes.json" + ) + ) + let response = try #require( + HTTPURLResponse( + url: expectedURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + ) + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypePageTransport( + expectedURL: expectedURL, + response: response, + data: Data("{}".utf8) + ) + ) + + // -- Act -- + _ = try await client.fetchType(named: name, technology: "Foundation") + + // -- Assert -- + // The transport rejects any request that does not use the expected nested URL. } @Test("reports unsuccessful documentation responses") @@ -90,7 +122,7 @@ struct AppleDocumentationClientTests { let response = try #require( HTTPURLResponse( url: expectedURL, - statusCode: 404, + statusCode: 500, httpVersion: nil, headerFields: nil ) @@ -107,44 +139,11 @@ struct AppleDocumentationClientTests { _ = try await client.fetchType(named: "MissingType", technology: "MetricKit") Issue.record("Expected the request to fail") } catch let error as DefaultAppleDocumentationClient.Error { - #expect(error == .httpStatus(404)) + #expect(error == .httpStatus(500)) } } } -private let untitledImageReferencePageData = Data( - """ - { - "abstract": [{"text": "A Unicode string value.", "type": "text"}], - "metadata": { - "modules": [{"name": "Swift"}], - "platforms": [], - "roleHeading": "Structure", - "symbolKind": "struct", - "title": "String" - }, - "primaryContentSections": [], - "references": { - "Swift-PageImage-card.png": { - "alt": "An orange Swift logo on a gradient background.", - "identifier": "Swift-PageImage-card.png", - "type": "image", - "variants": [ - { - "traits": ["2x", "light"], - "url": "/images/com.apple.Swift/Swift-PageImage-card@2x.png" - }, - { - "traits": ["2x", "dark"], - "url": "/images/com.apple.Swift/Swift-PageImage-card~dark@2x.png" - } - ] - } - } - } - """.utf8 -) - private struct TypePageTransport: HTTPDataTransport { let expectedURL: URL let response: URLResponse diff --git a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift new file mode 100644 index 0000000..43b1580 --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Apple documentation type catalog client") +struct AppleDocumentationClientTypeListTests { + @Test("lists direct symbols from a technology root document") + func listsTechnologyRootSymbols() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypeCatalogTestTransport( + responses: [rootURL: .init(statusCode: 200, data: swiftDataRootData)] + ) + ) + + // -- Act -- + let types = try await client.fetchTypes(technology: "SwiftData") + + // -- Assert -- + #expect(types.map(\.name) == ["Index(_:)", "Model()"]) + #expect(types.map(\.kind) == ["macro", "macro"]) + #expect(types.map(\.path) == ["index(_:)-74ia2", "model()"]) + #expect( + types.map(\.url) + == [ + "https://developer.apple.com/documentation/swiftdata/index(_:)-74ia2", + "https://developer.apple.com/documentation/swiftdata/model()", + ] + ) + } + + @Test("links to unsupported external technology documentation") + func linksUnsupportedTechnology() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/carekit.json") + ) + let technologiesURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: TypeCatalogTestTransport( + responses: [ + rootURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: externalTechnologyCatalogData), + ] + ) + ) + + // -- Act -- + do { + _ = try await client.fetchTypes(technology: "CareKit") + Issue.record("Expected the request to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + Type retrieval is unavailable for CareKit. + + Continue in the technology documentation: + https://carekit-apple.github.io/CareKit/documentation/carekit + """ + ) + } + } +} + +private let swiftDataRootData = Data( + """ + { + "references": { + "doc://module": { + "fragments": [{"kind": "identifier", "text": "SwiftData"}], + "kind": "symbol", + "role": "collection", + "title": "SwiftData", + "url": "/documentation/swiftdata" + }, + "doc://article": { + "kind": "article", + "role": "article", + "title": "Using SwiftData", + "url": "/documentation/swiftdata/using-swiftdata" + }, + "doc://model": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Model()", + "url": "/documentation/swiftdata/model()" + }, + "doc://index": { + "fragments": [{"kind": "keyword", "text": "macro"}], + "kind": "symbol", + "role": "symbol", + "title": "Index(_:)", + "url": "/documentation/swiftdata/index(_:)-74ia2" + } + } + } + """.utf8 +) + +private let externalTechnologyCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "https://carekit-apple.github.io/CareKit/documentation/carekit" + }, + "title": "CareKit" + }] + }] + }] + } + """.utf8 +) + +private struct TypeCatalogTestTransport: HTTPDataTransport { + struct Response: Sendable { + let statusCode: Int + let data: Data + } + + let responses: [URL: Response] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let result = responses[url] else { + throw TypeCatalogTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: result.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (result.data, response) + } +} + +private enum TypeCatalogTestError: Error { + case unexpectedURL(URL) +} diff --git a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift new file mode 100644 index 0000000..eb52245 --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import CLI + +@Suite("Types list command runner") +struct TypesListCommandRunnerTests { + @Test("fetches and renders direct types for the requested technology") + func fetchesAndRendersTypes() async throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let runner = TypesListCommandRunner( + client: RequestedTypesClient(types: types), + renderer: RequestedTypesRenderer(expectedTypes: types) + ) + + // -- Act -- + let result = try await runner.run(technology: "SwiftData") + + // -- Assert -- + #expect(result.output == "rendered types") + #expect(result.typeCount == 1) + } +} + +private struct RequestedTypesClient: DocumentationTypeCatalogClient { + let types: [DocumentationType] + + func fetchTypes(technology: String) async throws -> [DocumentationType] { + guard technology == "SwiftData" else { + throw TypesListRunnerTestError.unexpectedTechnology + } + return types + } +} + +private struct RequestedTypesRenderer: DocumentationTypeListRenderer { + let expectedTypes: [DocumentationType] + + func render(_ types: [DocumentationType]) throws -> String { + guard types == expectedTypes else { + throw TypesListRunnerTestError.unexpectedTypes + } + return "rendered types" + } +} + +private enum TypesListRunnerTestError: Error { + case unexpectedTechnology + case unexpectedTypes +} diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index c37adfa..dd4f438 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -24,15 +24,14 @@ struct TypesViewCommandRunnerTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - let document = TypeDocumentationDocument(data: data, page: page) + let document = TypeDocumentationDocument(data: data) let client = RequestedTypeClient( expectedName: "MXHangDiagnostic", expectedTechnology: "MetricKit", document: document ) let renderer = RequestedTypeRenderer( - expectedTitle: "MXHangDiagnostic", + expectedData: data, output: "rendered documentation" ) let runner = TypesViewCommandRunner(client: client, renderer: renderer) @@ -63,11 +62,11 @@ private struct RequestedTypeClient: AppleDocumentationClient { } private struct RequestedTypeRenderer: TypeDocumentationRenderer { - let expectedTitle: String + let expectedData: Data let output: String func render(_ document: TypeDocumentationDocument) -> String { - guard document.page.metadata.title == expectedTitle else { + guard document.data == expectedData else { return "unexpected document" } return output diff --git a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift index 21d9a50..9400f7e 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift @@ -2,8 +2,26 @@ import Testing @testable import CLI -@Suite("Types view command parsing") +@Suite("Types command parsing") struct TypesViewCommandTests { + @Test("accepts the types list command with a required technology") + func parsesTypesList() throws { + // -- Arrange -- + let arguments = [ + "types", "list", + "--technology", "MetricKit", + "--json", + ] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + let listCommand = try #require(command as? TypesListCommand) + #expect(listCommand.technology == "MetricKit") + #expect(listCommand.json) + } + @Test("accepts a type name and required technology option") func parsesTypeNameAndTechnology() throws { // -- Arrange -- diff --git a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift new file mode 100644 index 0000000..bd9dede --- /dev/null +++ b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift @@ -0,0 +1,62 @@ +import Testing + +@testable import CLI + +@Suite("Default documentation type list renderer") +struct DefaultDocumentationTypeListRendererTests { + @Test("renders documentation types as a table") + func rendersTable() throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let renderer = DefaultDocumentationTypeListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render(types) + + // -- Assert -- + #expect( + output == """ + SYMBOL KIND PATH URL + Model() macro model() https://developer.apple.com/documentation/swiftdata/model() + """ + ) + } + + @Test("renders documentation types as JSON") + func rendersJSON() throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()", + kind: "macro", + path: "model()", + url: "https://developer.apple.com/documentation/swiftdata/model()" + ) + ] + let renderer = DefaultDocumentationTypeListRenderer(output: .json) + + // -- Act -- + let output = try renderer.render(types) + + // -- Assert -- + #expect( + output == """ + [ + { + "kind" : "macro", + "name" : "Model()", + "path" : "model()", + "url" : "https://developer.apple.com/documentation/swiftdata/model()" + } + ] + """ + ) + } +} diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index d617bbd..3b7a186 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -24,7 +24,7 @@ struct DefaultTypeDocumentationRendererTests { let document = try makeDocument(rawJSON) let renderer = DefaultTypeDocumentationRenderer(output: .text) - let output = renderer.render(document) + let output = try renderer.render(document) #expect( output == """ @@ -38,31 +38,20 @@ struct DefaultTypeDocumentationRendererTests { @Test("returns Apple's DocC JSON unchanged") func rendersRawJSON() throws { - let rawJSON = """ - { - "abstract": [], - "metadata": { - "modules": [{"name": "MetricKit"}], - "platforms": [], - "roleHeading": "Class", - "symbolKind": "class", - "title": "MXHangDiagnostic" - }, - "primaryContentSections": [], - "references": {} - } - """ + // -- Arrange -- + // This intentionally omits the fields required by the text renderer. + let rawJSON = "{\"newUpstreamShape\":true}" let document = try makeDocument(rawJSON) let renderer = DefaultTypeDocumentationRenderer(output: .json) - let output = renderer.render(document) + // -- Act -- + let output = try renderer.render(document) + // -- Assert -- #expect(output == rawJSON) } private func makeDocument(_ rawJSON: String) throws -> TypeDocumentationDocument { - let data = Data(rawJSON.utf8) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) - return TypeDocumentationDocument(data: data, page: page) + TypeDocumentationDocument(data: Data(rawJSON.utf8)) } } diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift new file mode 100644 index 0000000..a49260c --- /dev/null +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Text type documentation reference rendering") +struct TextTypeDocumentationRendererReferenceTests { + @Test("renders inline references and follow-up links in documentation sections") + func rendersInlineReferencesAndLinks() throws { + // -- Arrange -- + let data = Data( + """ + { + "abstract": [], + "metadata": { + "modules": [{"name": "SwiftUI"}], + "platforms": [], + "roleHeading": "Structure", + "symbolKind": "struct", + "title": "Button" + }, + "primaryContentSections": [], + "references": { + "doc://button-init": { + "abstract": [ + {"text": "Creates a button that performs an ", "type": "text"}, + {"code": "AppIntent", "type": "codeVoice"}, + {"text": ".", "type": "text"} + ], + "kind": "symbol", + "role": "symbol", + "title": "init(intent:label:)", + "type": "topic", + "url": "/documentation/swiftui/button/init(intent:label:)" + } + }, + "topicSections": [{ + "identifiers": ["doc://button-init"], + "title": "Creating a button" + }] + } + """.utf8 + ) + let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("init(intent:label:) — Creates a button that performs an AppIntent.")) + #expect( + output.contains( + "https://developer.apple.com/documentation/swiftui/button/init(intent:label:)" + ) + ) + } +} diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift index 239c629..68df12d 100644 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -31,6 +31,31 @@ struct SentryCommandContextTests { #expect(context.logMetadata.keys.sorted() == expectedKeys) } + @Test("opts technology and output mode into types list telemetry") + func includesTypesListContext() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesList( + technology: "SwiftData", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.list") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftData") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + @Test("excludes the skill name from agent command telemetry") func excludesAgentSkillName() { // -- Arrange -- diff --git a/Tests/CLITests/telemetry/SentryConfigurationTests.swift b/Tests/CLITests/telemetry/SentryConfigurationTests.swift index 5663f63..c1f4f7a 100644 --- a/Tests/CLITests/telemetry/SentryConfigurationTests.swift +++ b/Tests/CLITests/telemetry/SentryConfigurationTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CLI @@ -31,6 +32,23 @@ struct SentryConfigurationTests { #expect(!enabled) } + @Test("treats documentation lookup failures as expected command errors") + func treatsLookupFailureAsExpected() { + // -- Arrange -- + let error = DefaultAppleDocumentationClient.Error.typeNotFound( + name: "Model", + technology: "SwiftData", + suggestion: nil, + technologyURL: "https://developer.apple.com/documentation/swiftdata" + ) + + // -- Act -- + let expected = SentryConfiguration.isExpected(error: error) + + // -- Assert -- + #expect(expected) + } + @Test("keeps telemetry enabled for other environmental flag values") func ignoresOtherFlagValues() { // -- Arrange -- diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index ad4e9a1..29a68e7 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,7 +4,7 @@ ## Principles -Telemetry follows an explicit opt-in model: +Telemetry follows an explicit allowlist model and is enabled by default. Set `TELEMETRY_DISABLED=true` to opt out: - Capture only fields that have been reviewed and approved. - Treat new commands, arguments, and metadata as private by default. @@ -30,6 +30,7 @@ Each leaf command starts a `console.command` transaction and opts in to a fixed | Command | Captured fields | | ------------------- | ------------------------------------------------------------------ | | `types view` | Command name, documentation type, technology, and JSON output mode | +| `types list` | Command name, technology, and JSON output mode | | `technologies list` | Command name and JSON output mode | | `agent skills list` | Command name | | `agent skills get` | Command name | @@ -56,6 +57,7 @@ The CLI records: - `apple_docs.type.requested` to measure type popularity within a technology. - `apple_docs.response.size` to monitor Apple documentation response sizes. - `apple_docs.technology.catalog.count` to monitor the size of the technology catalog. +- `apple_docs.type.catalog.count` to monitor direct type counts in technology root documents. Metric names and attributes are allowlisted. Type and technology are the only variable popularity dimensions. From 07747e842faac1c2bc8e0493ef473cab66e0550c Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Sep 2026 14:08:41 +0200 Subject: [PATCH 2/2] fix(cli): Map failed type catalog retries Return actionable unsupported-technology guidance when a catalog-resolved DocC root also returns 404. Avoid repeating the same case-insensitive request when the original path already used the resolved slug. --- .../CLI/client/AppleDocumentationClient.swift | 7 ++- ...ppleDocumentationClientTypeListTests.swift | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index b11b039..eef904f 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -145,9 +145,14 @@ struct DefaultAppleDocumentationClient