diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 976e822..993a663 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -215,7 +215,7 @@ jobs: "dist/apple-docs-${{ matrix.platform }}" \ types view String --technology Swift --json \ > "$RUNNER_TEMP/string.json" - jq -e '.metadata.title == "String"' "$RUNNER_TEMP/string.json" + jq -e '.title == "String"' "$RUNNER_TEMP/string.json" - name: Upload artifact uses: actions/upload-artifact@v7 diff --git a/AGENTS.md b/AGENTS.md index 6c151d3..f3d3a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ - Use a gated `Dependencies` constraint when a default implementation needs injectable collaborators. - Keep `TypesViewCommandRunner` focused on orchestration. HTTP access and output rendering belong behind their respective abstractions. - Validate HTTP status and response types at the external boundary. -- Preserve Apple’s response bytes unchanged for `--json` output. +- Derive `--json` output from the shared semantic presentation model. `--agent` selects the audience independently. ## Workflow diff --git a/README.md b/README.md index cf96111..7dab8ed 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - Read APIs, inspect declarations, platform availability, conformances, and documented members. - Discover symbols, list technologies and search their documentation collections. -- Use in scripts and agents to retrieve structured results, unchanged DocC documents, and bundled Agent Skills. +- Use in scripts and agents to retrieve structured results, agent Markdown, and bundled Agent Skills. > [!NOTE] > This project is not affiliated with, endorsed by, or sponsored by Apple Inc. It is an independent tool for accessing Apple Developer documentation. @@ -123,7 +123,7 @@ The terminal output includes available information such as: - Documented members and related APIs - Canonical Apple Developer URL -Text rendering also supports sparse article and collection pages, resolves reference links, and strips remote terminal control characters. It uses normalized documentation content, which does not cover every upstream DocC field. Use `--json` when you need the original document. +Text rendering also supports sparse article and collection pages, resolves reference links, and strips remote terminal control characters. It uses normalized documentation content, which does not cover every upstream DocC field. JSON uses the same normalized content, not the original DocC document. Every `types` command requires `--technology`. The CLI does not persist a selected framework. Nested symbols accept either dotted Swift spelling or slash-separated DocC paths: @@ -144,31 +144,34 @@ apple-docs technologies list --agent Agent output uses the same normalized documentation content as human text. Follow-up commands are included only for destinations the CLI can represent safely. All commands remain one-shot, even when run in a terminal. -`--agent` no longer aliases `--json`. Existing scripts that require JSON should use `--json`, which takes precedence if both flags are supplied. +`--agent` selects the audience and `--json` selects the format independently. Combine them for JSON with agent navigation commands. ### JSON output -The documentation commands accept `--json`, but their output contracts differ: +The documentation commands accept `--json` for normalized semantic output: -| Command | JSON output | -| ----------------------------- | ------------------------------------------------------------------------- | -| `types view` | Apple's upstream DocC document, with its response bytes unchanged. | -| `types list` / `types search` | An array of symbol results with `name`, `kind`, `path`, and `url` fields. | -| `technologies list` | The sorted catalog as an array of `name` and `identifier` objects. | +| Command | JSON output | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types view` | An object with `title`, `kind`, `technology`, `path`, `url`, `modules`, `abstract`, `deprecation`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. | +| `types list` / `types search` | An array with `name`, `kind`, `path`, and `url` fields. | +| `technologies list` | A sorted array of `name` and `identifier` objects. | + +Add `--agent` to include `navigation` commands for supported destinations. Content blocks retain semantic types and links distinguish documentation, external, and unavailable targets. Availability includes `introducedAt`, `deprecatedAt`, `obsoletedAt`, `isBeta`, and `isUnavailable` when applicable. Inapplicable optional fields are omitted. ```bash -apple-docs types view MXHangDiagnostic --technology MetricKit --json -apple-docs types list --technology MetricKit --json -apple-docs types search Button --technology SwiftUI --json +apple-docs types view String --technology Swift --json +apple-docs types view String --technology Swift --agent --json +apple-docs types search Button --technology SwiftUI --agent --json ``` For example, extract the documented title using [jq](https://jqlang.org/), installed separately: ```bash -apple-docs types view MXHangDiagnostic --technology MetricKit --json \ - | jq -r '.metadata.title' +apple-docs types view String --technology Swift --json | jq -r '.title' ``` +This is a breaking change: page JSON is normalized rather than raw DocC, and title extraction uses `.title` instead of `.metadata.title`. There is no raw-DocC export flag. Normalization does not preserve every upstream field, so missing normalized content is not evidence that Apple supplies no such information. JSON stdout contains only the result, with warnings and verbose diagnostics sent to stderr. + ### Cache Stateless command selection does not mean responses are never cached. To clear cached Apple documentation responses: diff --git a/Sources/CLI/documentation/DocumentationPage.swift b/Sources/CLI/documentation/DocumentationPage.swift index 154fe71..5d8689f 100644 --- a/Sources/CLI/documentation/DocumentationPage.swift +++ b/Sources/CLI/documentation/DocumentationPage.swift @@ -34,12 +34,12 @@ indirect enum DocumentationBlock: Equatable, Sendable { case aside(content: [DocumentationBlock], style: String, name: String?) } -struct DocumentationDeclaration: Equatable, Sendable { +struct DocumentationDeclaration: Encodable, Equatable, Sendable { let languages: [String] let text: String } -struct DocumentationAvailability: Equatable, Sendable { +struct DocumentationAvailability: Encodable, Equatable, Sendable { let name: String let introducedAt: String? let deprecatedAt: String? diff --git a/Sources/CLI/main/OutputOptions.swift b/Sources/CLI/main/OutputOptions.swift index 218a734..718f3b5 100644 --- a/Sources/CLI/main/OutputOptions.swift +++ b/Sources/CLI/main/OutputOptions.swift @@ -9,12 +9,12 @@ enum OutputFormat: Equatable, Sendable { } struct OutputOptions: ParsableArguments { - @Flag(help: "Print JSON. Page output preserves Apple's raw DocC document, even with --agent.") + @Flag(help: "Print normalized semantic JSON. Combine with --agent to include follow-up commands.") var json = false - @Flag(help: "Print agent-oriented Markdown with follow-up commands. --json takes precedence.") + @Flag(help: "Print agent-oriented Markdown with follow-up commands. Combine with --json for structured output.") var agent = false - var audience: OutputAudience { agent && !json ? .agent : .human } + var audience: OutputAudience { agent ? .agent : .human } var format: OutputFormat { json ? .json : .text } } diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift index a37a006..8779d21 100644 --- a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -25,11 +25,8 @@ struct DefaultDocumentationTypeListRenderer: Sendable { } return terminalSafeText(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) + let presentation = DocumentationPresenter().symbols(types, technology: technology, audience: audience) + return try StructuredDocumentationRenderer().render(presentation) } } diff --git a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift index 60ed978..685ed41 100644 --- a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift +++ b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift @@ -23,11 +23,8 @@ struct DefaultTechnologyListRenderer: Sendable { } return terminalSafeText(renderTable(technologies)) 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(technologies), as: UTF8.self) + let presentation = DocumentationPresenter().technologies(technologies, audience: audience) + return try StructuredDocumentationRenderer().render(presentation) } } diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 1b67f52..429df21 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -5,13 +5,15 @@ struct DefaultTypeDocumentationRenderer: Sendable { var audience: OutputAudience = .human func render(_ document: TypeDocumentationDocument) throws -> String { - if output == .json { - return RawJSONTypeDocumentationRenderer().render(document) - } let page = try DocumentationPageDecoder().decode(document.data, destination: document.destination) let presentation = DocumentationPresenter().page(page, audience: audience) - return audience == .agent - ? AgentDocumentationRenderer().render(presentation) - : TextTypeDocumentationRenderer().render(page) + switch output { + case .json: + return try StructuredDocumentationRenderer().render(presentation) + case .text: + return audience == .agent + ? AgentDocumentationRenderer().render(presentation) + : TextTypeDocumentationRenderer().render(page) + } } } diff --git a/Sources/CLI/renderer/DocumentationPresentation.swift b/Sources/CLI/renderer/DocumentationPresentation.swift index a8c571e..6d86787 100644 --- a/Sources/CLI/renderer/DocumentationPresentation.swift +++ b/Sources/CLI/renderer/DocumentationPresentation.swift @@ -1,30 +1,70 @@ -struct AgentNavigation: Equatable, Sendable { +import Foundation + +struct AgentNavigation: Encodable, Equatable, Sendable { let technology: String let path: String? let command: String } -struct PagePresentation: Sendable { +struct PagePresentation: Encodable, Sendable { let document: DocumentationPage let audience: OutputAudience let navigation: AgentNavigation? let relationships: [GroupPresentation] let topics: [GroupPresentation] let seeAlso: [GroupPresentation] + + private enum CodingKeys: CodingKey { + case title, kind, technology, path, url, modules, abstract, deprecation, declarations, availability, content + case relationships, topics, seeAlso, navigation + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(document.title, forKey: .title) + try container.encode(document.kind, forKey: .kind) + try container.encode(document.destination.technology, forKey: .technology) + try container.encode(document.destination.path, forKey: .path) + try container.encode(document.url, forKey: .url) + try container.encode(document.modules, forKey: .modules) + try container.encode(document.abstract, forKey: .abstract) + try container.encode(document.deprecation, forKey: .deprecation) + try container.encode(document.declarations, forKey: .declarations) + try container.encode(document.availability, forKey: .availability) + try container.encode(document.content, forKey: .content) + try container.encode(relationships, forKey: .relationships) + try container.encode(topics, forKey: .topics) + try container.encode(seeAlso, forKey: .seeAlso) + try container.encodeIfPresent(navigation, forKey: .navigation) + } } -struct GroupPresentation: Sendable { +struct GroupPresentation: Encodable, Sendable { let id: String let title: String let references: [ReferencePresentation] } -struct ReferencePresentation: Sendable { +struct ReferencePresentation: Encodable, Sendable { let reference: DocumentationReference let navigation: AgentNavigation? + + private enum CodingKeys: CodingKey { + case id, title, kind, abstract, target, navigation + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(reference.id, forKey: .id) + try container.encode(reference.title, forKey: .title) + try container.encode(reference.kind, forKey: .kind) + try container.encode(reference.abstract, forKey: .abstract) + try container.encode(PresentedLinkTarget(target: reference.target), forKey: .target) + try container.encodeIfPresent(navigation, forKey: .navigation) + } } -struct SymbolPresentation: Sendable { +struct SymbolPresentation: Encodable, Sendable { let name: String let kind: String let path: String @@ -32,8 +72,93 @@ struct SymbolPresentation: Sendable { let navigation: AgentNavigation? } -struct TechnologyPresentation: Sendable { +struct TechnologyPresentation: Encodable, Sendable { let name: String let identifier: String let navigation: AgentNavigation? } + +private struct PresentedLinkTarget: Encodable { + let target: DocumentationLinkTarget + + private enum CodingKeys: CodingKey { + case type, technology, path, fragment, url, label + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch target { + case .documentation(let destination): + try container.encode("documentation", forKey: .type) + try container.encode(destination.technology, forKey: .technology) + try container.encode(destination.path, forKey: .path) + try container.encodeIfPresent(destination.fragment, forKey: .fragment) + try container.encode(destination.url, forKey: .url) + case .external(let url): + try container.encode("external", forKey: .type) + try container.encode(url, forKey: .url) + case .unavailable(let label): + try container.encode("unavailable", forKey: .type) + try container.encode(label, forKey: .label) + } + } +} + +extension DocumentationInline: Encodable { + private enum CodingKeys: CodingKey { + case type, text, target + } + + var text: String { + switch self { + case .text(let text), .code(let text): return text + case .link(let label, _): return label.map(\.text).joined() + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .text) + switch self { + case .text: try container.encode("text", forKey: .type) + case .code: try container.encode("code", forKey: .type) + case .link(_, let target): + try container.encode("link", forKey: .type) + try container.encode(PresentedLinkTarget(target: target), forKey: .target) + } + } +} + +extension DocumentationBlock: Encodable { + private enum CodingKeys: CodingKey { + case type, inlineContent, text, code, syntax, items, startIndex, content, style, name + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .paragraph(let content): + try container.encode("paragraph", forKey: .type) + try container.encode(content, forKey: .inlineContent) + case .heading(let text): + try container.encode("heading", forKey: .type) + try container.encode(text, forKey: .text) + case .codeListing(let code, let syntax): + try container.encode("codeListing", forKey: .type) + try container.encode(code, forKey: .code) + try container.encodeIfPresent(syntax, forKey: .syntax) + case .orderedList(let items, let start): + try container.encode("orderedList", forKey: .type) + try container.encode(items, forKey: .items) + try container.encode(start, forKey: .startIndex) + case .unorderedList(let items): + try container.encode("unorderedList", forKey: .type) + try container.encode(items, forKey: .items) + case .aside(let content, let style, let name): + try container.encode("aside", forKey: .type) + try container.encode(content, forKey: .content) + try container.encode(style, forKey: .style) + try container.encodeIfPresent(name, forKey: .name) + } + } +} diff --git a/Sources/CLI/renderer/RawJSONTypeDocumentationRenderer.swift b/Sources/CLI/renderer/RawJSONTypeDocumentationRenderer.swift deleted file mode 100644 index 43c4952..0000000 --- a/Sources/CLI/renderer/RawJSONTypeDocumentationRenderer.swift +++ /dev/null @@ -1,7 +0,0 @@ -struct RawJSONTypeDocumentationRenderer: Sendable { - func render(_ document: TypeDocumentationDocument) -> String { - // DocC JSON has already passed decoding, so preserve a non-optional rendering contract. - // swiftlint:disable:next optional_data_string_conversion - return String(decoding: document.data, as: UTF8.self) - } -} diff --git a/Sources/CLI/renderer/StructuredDocumentationRenderer.swift b/Sources/CLI/renderer/StructuredDocumentationRenderer.swift new file mode 100644 index 0000000..5bda784 --- /dev/null +++ b/Sources/CLI/renderer/StructuredDocumentationRenderer.swift @@ -0,0 +1,10 @@ +import Foundation + +struct StructuredDocumentationRenderer: Sendable { + func render(_ presentation: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + // JSONEncoder guarantees UTF-8, so this conversion cannot fail. + return String(data: try encoder.encode(presentation), encoding: .utf8)! + } +} diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index 55f6f3c..8255ea0 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -75,25 +75,25 @@ enum BundledAgentSkills { From a returned `/documentation/foundation/...` URL, pass only the part after `/documentation/foundation/`. Quote paths containing parentheses or other shell metacharacters. Do not pass a full URL as the type argument. - Text output includes available summaries, declarations, availability, relationships, topics, and links. + Text output includes available summaries, Swift declarations, availability, relationships, topics, and links. Follow Topics and See Also links to inspect member behavior, rather than extrapolating from a type. If a linked API belongs to another technology, change `--technology` accordingly. ## Structured evidence - Use `--agent` for complete normalized Markdown and safely quoted commands for supported destinations. - `--json` takes precedence and retains raw DocC. Neither flag is global or auto-detected. + Use `--agent` for one-shot Markdown with follow-up commands, even in a terminal. Add `--json` for structured + agent output. These flags are independent and apply only to documentation commands, not globally. ```bash apple-docs technologies list --agent - apple-docs types search URLSession --technology Foundation --json - apple-docs types view URLSession --technology Foundation --json + apple-docs types search URLSession --technology Foundation --agent --json + apple-docs types view URLSession --technology Foundation --agent --json ``` - Technology, list, and search JSON are CLI-produced arrays. `types view --json` preserves Apple's raw DocC - response bytes. Inspect it when the normalized text view omits upstream detail. Useful sections - include `metadata`, `primaryContentSections`, `topicSections`, `references`, and `variants`. Fields vary. - Resolve topic identifiers through `references` to find member URLs. A missing field is not a guarantee. + Technology, list, and search JSON are arrays. Page JSON is a normalized semantic object, not raw DocC bytes. + Inspect `title`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. + Agent output adds `navigation` commands where a destination can be represented safely on the CLI. + Follow validated targets in grouped references. A missing field is not a guarantee. ## Availability and examples @@ -150,7 +150,7 @@ enum BundledAgentSkills { ```bash apple-docs technologies list --agent - apple-docs technologies list --json + apple-docs technologies list --agent --json ``` Select likely frameworks using returned names or documentation slugs. A catalog entry is not a guarantee that @@ -162,7 +162,7 @@ enum BundledAgentSkills { ```bash apple-docs types list --technology SwiftUI --agent apple-docs types search Button --technology SwiftUI --agent - apple-docs types search Button --technology SwiftUI --json + apple-docs types search Button --technology SwiftUI --agent --json ``` - Start with a concise symbol-name fragment rather than a natural-language question. Search matches names and @@ -181,8 +181,8 @@ enum BundledAgentSkills { apple-docs types view URLSession.AsyncBytes --technology Foundation --agent ``` - Prefer the exact `path` from a search result. For nested members, inspect the parent type's Topics or raw - `references` and copy the relevant technology-relative path, including any suffix. Quote it in shell commands. + Prefer the returned agent navigation command or exact `path` from a search result. For nested members, + inspect the parent type's Topics and copy the technology-relative path, including any suffix. Quote shell paths. Do not conclude that a member is missing just because `types search` did not find it. Compare relevant candidates using their documented purpose, declarations, platform availability, and caveats. @@ -220,15 +220,15 @@ enum BundledAgentSkills { ```bash apple-docs types view URLSession.AsyncBytes --technology Foundation --agent - apple-docs types view URLSession.AsyncBytes --technology Foundation --json + apple-docs types view URLSession.AsyncBytes --technology Foundation --agent --json ``` Read the availability and deprecation sections. For a method, initializer, or property, follow the containing - type's Topics or raw `references` to retrieve the member's path. Parent-type availability is not enough. + type's Topics and reference targets to retrieve the member's path. Parent-type availability is not enough. Use discovery if the symbol is unknown, and preserve DocC overload suffixes rather than guessing. - In raw DocC JSON, inspect `metadata.platforms` when present. Platform entries may provide `introducedAt`, - `deprecatedAt`, `obsoletedAt`, `unavailable`, or `beta`. Read `deprecationSummary`, declarations, and overview + In normalized JSON, inspect `availability`. Platform entries may provide `introducedAt`, + `deprecatedAt`, `obsoletedAt`, `isUnavailable`, or `isBeta`. Read `deprecation`, declarations, and overview content for qualifications or replacement advice. These fields are optional and differ between pages. ## Interpret conservatively diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index de677d2..f4dc2ff 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -20,9 +20,9 @@ struct AppleDocsCommandIntegrationTests { let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) // -- Assert -- - #expect(document.metadata.title == "String") - #expect(document.metadata.modules.map(\.name) == ["Swift"]) - #expect(document.metadata.symbolKind == "struct") + #expect(document.title == "String") + #expect(document.modules == ["Swift"]) + #expect(document.kind == "struct") } @Test( @@ -46,6 +46,22 @@ struct AppleDocsCommandIntegrationTests { #expect(!output.contains("\u{1B}")) } + @Test("agent JSON includes navigation without verbose diagnostics on stdout") + func rendersAgentJSON() throws { + // -- Arrange -- + let arguments = ["types", "view", "String", "--technology", "Swift", "--agent", "--json", "--verbose"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + + // -- Assert -- + #expect(value["title"] as? String == "String") + #expect(value["navigation"] != nil) + #expect(value["metadata"] == nil) + #expect(!output.contains("\u{1B}")) + } + @Test("returns Foundation URL documentation as JSON") func returnsFoundationURLJSON() throws { // -- Arrange -- @@ -56,9 +72,9 @@ struct AppleDocsCommandIntegrationTests { let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) // -- Assert -- - #expect(document.metadata.title == "URL") - #expect(document.metadata.modules.map(\.name) == ["Foundation"]) - #expect(document.metadata.symbolKind == "struct") + #expect(document.title == "URL") + #expect(document.modules == ["Foundation"]) + #expect(document.kind == "struct") } @Test("renders Swift String documentation as text") @@ -138,7 +154,7 @@ struct AppleDocsCommandIntegrationTests { let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) // -- Assert -- - #expect(document.metadata.title == "URLSession.AsyncBytes") + #expect(document.title == "URLSession.AsyncBytes") } @Test("lists stable technologies as JSON", arguments: [["--json"], ["--agent", "--json"]]) @@ -171,17 +187,9 @@ struct AppleDocsCommandIntegrationTests { } private struct TypeDocument: Decodable { - let metadata: Metadata - - struct Metadata: Decodable { - let modules: [Module] - let symbolKind: String - let title: String - } - - struct Module: Decodable { - let name: String - } + let modules: [String] + let kind: String + let title: String } private struct ListedType: Decodable, Equatable { diff --git a/Tests/CLITests/main/OutputOptionsTests.swift b/Tests/CLITests/main/OutputOptionsTests.swift index 3f3ec48..76708f6 100644 --- a/Tests/CLITests/main/OutputOptionsTests.swift +++ b/Tests/CLITests/main/OutputOptionsTests.swift @@ -10,8 +10,8 @@ struct OutputOptionsTests { ([], OutputAudience.human, OutputFormat.text), (["--agent"], .agent, .text), (["--json"], .human, .json), - (["--agent", "--json"], .human, .json), - (["--json", "--agent"], .human, .json), + (["--agent", "--json"], .agent, .json), + (["--json", "--agent"], .agent, .json), ]) func selectsOutput(flags: [String], audience: OutputAudience, format: OutputFormat) throws { // -- Arrange -- diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index d3ab01a..b142ebb 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -140,19 +140,36 @@ struct DefaultTypeDocumentationRendererTests { #expect(!output.contains("Declaration")) } - @Test("returns Apple's DocC JSON unchanged") - func rendersRawJSON() throws { + @Test("renders semantic JSON with optional agent navigation", arguments: [OutputAudience.human, .agent]) + func rendersSemanticJSON(audience: OutputAudience) throws { // -- 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 document = try makeDocument( + #"{"metadata":{"title":"MXHangDiagnostic","symbolKind":"class"},"unknownField":true}"#) + let renderer = DefaultTypeDocumentationRenderer(output: .json, audience: audience) // -- Act -- let output = try renderer.render(document) + let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + + // -- Assert -- + #expect(value["title"] as? String == "MXHangDiagnostic") + #expect(value["kind"] as? String == "class") + #expect(value["metadata"] == nil) + #expect(value["unknownField"] == nil) + #expect((value["navigation"] != nil) == (audience == .agent)) + } + + @Test("JSON rejects documents without required semantic metadata") + func rejectsMalformedJSONPage() throws { + // -- Arrange -- + let document = try makeDocument(#"{"unknownField":true}"#) + let renderer = DefaultTypeDocumentationRenderer(output: .json) + + // -- Act -- + let render = { try renderer.render(document) } // -- Assert -- - #expect(output == rawJSON) + #expect(throws: (any Error).self) { try render() } } private func makeDocument( diff --git a/Tests/CLITests/renderer/StructuredDocumentationRendererTests.swift b/Tests/CLITests/renderer/StructuredDocumentationRendererTests.swift new file mode 100644 index 0000000..8b081ec --- /dev/null +++ b/Tests/CLITests/renderer/StructuredDocumentationRendererTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Structured documentation rendering") +struct StructuredDocumentationRendererTests { + @Test("page JSON has stable semantic fields rather than raw DocC or rendered text") + func rendersPageContract() throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" + ) + let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) + let presentation = DocumentationPresenter().page(page, audience: .human) + + // -- Act -- + let output = try StructuredDocumentationRenderer().render(presentation) + let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + + // -- Assert -- + #expect( + Set(value.keys) + == Set([ + "title", "kind", "technology", "path", "url", "modules", "abstract", "deprecation", "declarations", + "availability", "content", "relationships", "topics", "seeAlso", + ])) + #expect(value["title"] as? String == "member(_:)") + #expect(value["technology"] as? String == "metrickit") + #expect(value["path"] as? String == "/documentation/metrickit/mxhangdiagnostic/member(_:)") + #expect(value["metadata"] == nil) + let abstract = try #require(value["abstract"] as? [[String: Any]]) + #expect(abstract[1]["type"] as? String == "code") + #expect(abstract[1]["text"] as? String == "value") + #expect(abstract[3]["type"] as? String == "link") + #expect(abstract[3]["text"] as? String == "a string") + let target = try #require(abstract[3]["target"] as? [String: Any]) + #expect(target["type"] as? String == "documentation") + #expect(target["path"] as? String == "/documentation/swift/string") + let content = try #require(value["content"] as? [[String: Any]]) + #expect( + content.map { $0["type"] as? String } == ["heading", "paragraph", "codeListing", "orderedList", "aside"]) + #expect(content[2]["code"] as? [String] == ["member(\"value\")", ""]) + #expect(content[2]["syntax"] as? String == "swift") + #expect(content[3]["startIndex"] as? Int == 3) + #expect(content[4]["style"] as? String == "warning") + #expect(content[4]["name"] as? String == "Important") + #expect(content[0]["items"] == nil) + #expect(!output.contains("\\/")) + #expect(!output.contains("\u{001B}")) + } + + @Test("agent JSON adds navigation on pages and references without dropping content") + func rendersAgentNavigation() throws { + // -- Arrange -- + let destination = DocumentationDestination(technology: "swift", path: "/documentation/swift/string") + let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) + let presentation = DocumentationPresenter().page(page, audience: .agent) + + // -- Act -- + let output = try StructuredDocumentationRenderer().render(presentation) + let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + + // -- Assert -- + let navigation = try #require(value["navigation"] as? [String: Any]) + #expect(navigation["command"] as? String == "apple-docs types view 'string' --technology 'swift' --agent") + let topics = try #require(value["topics"] as? [[String: Any]]) + let references = try #require(topics[0]["references"] as? [[String: Any]]) + #expect(references[0]["navigation"] != nil) + #expect((value["declarations"] as? [Any])?.count == 2) + #expect((value["content"] as? [Any])?.count == 5) + } + + @Test("discovery JSON remains a top-level array with existing result fields") + func rendersDiscoveryArrays() throws { + // -- Arrange -- + let symbols = [ + DocumentationType( + name: "String", kind: "struct", path: "string", + url: "https://developer.apple.com/documentation/swift/string") + ] + let technologies = [Technology(name: "Swift", identifier: "doc://swift/documentation/Swift")] + let presenter = DocumentationPresenter() + let renderer = StructuredDocumentationRenderer() + + // -- Act -- + let symbolText = try renderer.render(presenter.symbols(symbols, technology: "Swift", audience: .human)) + let technologyText = try renderer.render(presenter.technologies(technologies, audience: .human)) + let symbolValues = try #require(JSONSerialization.jsonObject(with: Data(symbolText.utf8)) as? [[String: Any]]) + let technologyValues = try #require( + JSONSerialization.jsonObject(with: Data(technologyText.utf8)) as? [[String: Any]]) + + // -- Assert -- + #expect(Set(symbolValues[0].keys) == Set(["name", "kind", "path", "url"])) + #expect(Set(technologyValues[0].keys) == Set(["name", "identifier"])) + #expect(symbolValues[0]["name"] as? String == "String") + #expect(technologyValues[0]["identifier"] as? String == "doc://swift/documentation/Swift") + } +}