From 4343282d0faa0523f57f53ded96abc2dff630662 Mon Sep 17 00:00:00 2001 From: Max Grebenets Date: Thu, 30 Oct 2025 18:00:56 +1100 Subject: [PATCH 1/3] (#1257) Combined module link issue - public extension of dependent module causes resolution failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The basic setup is this: ```swift // ImportedModule struct ExtendedType {} struct BaseType {} ``` ```swift // MainModule import ImportedModule extension ExtendedType { func extensionMethod() } ``` With the above we have the following issues: - `/ImportedModule/ExtendedType` **always** resolves to the type extension link - `/ImportModule/BaseType` links are broken Prioritize absolute links resolution to point to the module referenced in the link, so that `/ImportedModule/BaseType` finds the base type because the path starts with `/`. To refer to the extended type or method, the non-absolute paths work in Xcode and with docc: `ImportedModule/ExtendedType` resolves to the type extension page, `ImportedModule/ExtendedType/extensionMethod()` resolves to the extension method page. > ‼️🤖 Disclaimer: this is an AI-assisted change. While AI was utilized to identify and apply the initial fix and create initial version of the tests, the code has been reviewed and modified where needed. More importantly, I have tested the changes both with the sample project attached to the original issue and with a real work project where I'm using `xcodebuild docbuild`. In the real world project I'm dealing with similar setup. `ParentFramework` imports `ChildFramework` and extends `ChildType` and then uses paths like `/ChildFramework/ChildType` in the documentation. By passing `DOCC_EXEC` build setting to `xcodebuild docbuild` invocation along with `--enable-experimental-combined-documentation` and other required flags I was able to produce doc archives for the 2 modules, then `docc merge` them into one and in the resulting doc archive all cross-linking works as expected. I can also ue `ChildFramework/ChildType/extensionMethod()` links to create reference to the extension method doc page. --- .../Link Resolution/PathHierarchy+Find.swift | 4 +- .../Infrastructure/PathHierarchyTests.swift | 98 ++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift index a4ea9ad67c..7117fcabaa 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy+Find.swift @@ -84,7 +84,9 @@ extension PathHierarchy { if let moduleMatch = modules.first(where: { $0.matches(firstComponent) }) { return try searchForNode(descendingFrom: moduleMatch, pathComponents: remaining.dropFirst(), onlyFindSymbols: onlyFindSymbols, rawPathForError: rawPath) } - if modules.count == 1 { + // For absolute links, only use the single-module fallback if the first component doesn't match + // any module name + if modules.count == 1 && !isAbsolute { do { return try searchForNode(descendingFrom: modules.first!, pathComponents: remaining, onlyFindSymbols: onlyFindSymbols, rawPathForError: rawPath) } catch { diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index c7c54f5495..f9a317bb6d 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -3126,7 +3126,103 @@ class PathHierarchyTests: XCTestCase { try assertFindsPath("/MainModule/TopLevelProtocol/extensionMember(_:)", in: tree, asSymbolID: "extensionMember1") try assertFindsPath("/MainModule/TopLevelProtocol/InnerStruct/extensionMember(_:)", in: tree, asSymbolID: "extensionMember2") } - + + func testAbsoluteLinksToOtherModuleWithExtensions() async throws { + enableFeatureFlag(\.isExperimentalLinkHierarchySerializationEnabled) + + let importedProtocolID = "s:14ImportedModule12BaseProtocolP" + let importedTypeID = "s:14ImportedModule12ExtendedTypeV" + let extensionSymbolID = "s:e:s:14ImportedModule12ExtendedTypeV04MainC0E15extensionMethodyyF" + let extensionMethodID = "s:14ImportedModule12ExtendedTypeV04MainC0E15extensionMethodyyF" + let mainModuleTypeID = "s:10MainModule0A4TypeV" + + let extensionMixin = SymbolGraph.Symbol.Swift.Extension( + extendedModule: "ImportedModule", + typeKind: .struct, + constraints: [] + ) + + let catalog = Folder(name: "TestCatalog.docc", content: [ + JSONFile(name: "MainModule.symbols.json", content: makeSymbolGraph( + moduleName: "MainModule", + symbols: [ + makeSymbol(id: mainModuleTypeID, kind: .struct, pathComponents: ["MainType"]) + ] + )), + JSONFile(name: "MainModule@ImportedModule.symbols.json", content: makeSymbolGraph( + moduleName: "MainModule", + symbols: [ + makeSymbol(id: importedProtocolID, kind: .protocol, pathComponents: ["BaseProtocol"]), + makeSymbol(id: importedTypeID, kind: .struct, pathComponents: ["ExtendedType"]), + makeSymbol( + id: extensionSymbolID, + kind: .extension, + pathComponents: ["ExtendedType"], + otherMixins: [extensionMixin] + ), + makeSymbol( + id: extensionMethodID, + kind: .method, + pathComponents: ["ExtendedType", "extensionMethod()"], + otherMixins: [extensionMixin] + ) + ], + relationships: [ + .init( + source: extensionMethodID, + target: extensionSymbolID, + kind: .memberOf, + targetFallback: "ImportedModule.ExtendedType" + ), + .init( + source: extensionSymbolID, + target: importedTypeID, + kind: .extensionTo, + targetFallback: "ImportedModule.ExtendedType" + ) + ] + )) + ]) + + let (_, context) = try await loadBundle(catalog: catalog) + let tree = context.linkResolver.localResolver.pathHierarchy + + XCTAssertEqual(tree.modules.count, 1) + XCTAssertEqual(tree.modules.first?.name, "MainModule") + + let paths = tree.caseInsensitiveDisambiguatedPaths() + XCTAssertEqual(paths[importedProtocolID], "/MainModule/ImportedModule/BaseProtocol") + XCTAssertEqual(paths[importedTypeID], "/MainModule/ImportedModule/ExtendedType-struct") + XCTAssertEqual( + paths[extensionMethodID], + "/MainModule/ImportedModule/ExtendedType/extensionMethod()" + ) + + // Verify that symbols can be found at their correct paths + try assertFindsPath("/MainModule/ImportedModule/BaseProtocol", in: tree, asSymbolID: importedProtocolID) + try assertFindsPath("/MainModule/ImportedModule/ExtendedType-struct", in: tree, asSymbolID: importedTypeID) + try assertFindsPath( + "/MainModule/ImportedModule/ExtendedType/extensionMethod()", + in: tree, + asSymbolID: extensionMethodID + ) + + // Verify that absolute paths to non-existent modules throw moduleNotFound error + // This is the fix being tested: without it, single-module fallback would trigger incorrectly + try assertPathRaisesErrorMessage( + "/ImportedModule/BaseProtocol", + in: tree, + context: context, + expectedErrorMessage: "No module named 'ImportedModule'" + ) + try assertPathRaisesErrorMessage( + "/ImportedModule/ExtendedType", + in: tree, + context: context, + expectedErrorMessage: "No module named 'ImportedModule'" + ) + } + func testMissingRequiredMemberOfSymbolGraphRelationshipInOneLanguageAcrossManyPlatforms() async throws { // We make a best-effort attempt to create a valid path hierarchy, even if the symbol graph inputs are not valid. From 9a14da06d80f62a9ac88f3b8e1b11e0d0ce87511 Mon Sep 17 00:00:00 2001 From: Maksym Grebenets Date: Wed, 12 Nov 2025 17:42:39 +1100 Subject: [PATCH 2/3] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Rönnqvist --- .../Infrastructure/PathHierarchyTests.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index f9a317bb6d..88edd23a94 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -3143,17 +3143,10 @@ class PathHierarchyTests: XCTestCase { ) let catalog = Folder(name: "TestCatalog.docc", content: [ - JSONFile(name: "MainModule.symbols.json", content: makeSymbolGraph( - moduleName: "MainModule", - symbols: [ - makeSymbol(id: mainModuleTypeID, kind: .struct, pathComponents: ["MainType"]) - ] - )), + JSONFile(name: "MainModule.symbols.json", content: makeSymbolGraph(moduleName: "MainModule", symbols: [])), JSONFile(name: "MainModule@ImportedModule.symbols.json", content: makeSymbolGraph( moduleName: "MainModule", symbols: [ - makeSymbol(id: importedProtocolID, kind: .protocol, pathComponents: ["BaseProtocol"]), - makeSymbol(id: importedTypeID, kind: .struct, pathComponents: ["ExtendedType"]), makeSymbol( id: extensionSymbolID, kind: .extension, From cee75551171d1e6654e707e18e28fe0a337076e9 Mon Sep 17 00:00:00 2001 From: Max Grebenets Date: Wed, 12 Nov 2025 20:47:14 +1100 Subject: [PATCH 3/3] Address feedback comments - Cleanup unrelated assertions - Use readable names for IDs - Cleanup unused IDs - Make sure the tests clearly show that relative paths resolve and absolute paths thoe 'no module' error - Use ExtendedModule name --- .../Infrastructure/PathHierarchyTests.swift | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index 88edd23a94..64de692897 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -3130,25 +3130,23 @@ class PathHierarchyTests: XCTestCase { func testAbsoluteLinksToOtherModuleWithExtensions() async throws { enableFeatureFlag(\.isExperimentalLinkHierarchySerializationEnabled) - let importedProtocolID = "s:14ImportedModule12BaseProtocolP" - let importedTypeID = "s:14ImportedModule12ExtendedTypeV" - let extensionSymbolID = "s:e:s:14ImportedModule12ExtendedTypeV04MainC0E15extensionMethodyyF" - let extensionMethodID = "s:14ImportedModule12ExtendedTypeV04MainC0E15extensionMethodyyF" - let mainModuleTypeID = "s:10MainModule0A4TypeV" + let extendedTypeID = "extended-type-id" + let extensionID = "extension-id" + let extensionMethodID = "extension-method-id" let extensionMixin = SymbolGraph.Symbol.Swift.Extension( - extendedModule: "ImportedModule", + extendedModule: "ExtendedModule", typeKind: .struct, constraints: [] ) let catalog = Folder(name: "TestCatalog.docc", content: [ JSONFile(name: "MainModule.symbols.json", content: makeSymbolGraph(moduleName: "MainModule", symbols: [])), - JSONFile(name: "MainModule@ImportedModule.symbols.json", content: makeSymbolGraph( + JSONFile(name: "MainModule@ExtendedModule.symbols.json", content: makeSymbolGraph( moduleName: "MainModule", symbols: [ makeSymbol( - id: extensionSymbolID, + id: extensionID, kind: .extension, pathComponents: ["ExtendedType"], otherMixins: [extensionMixin] @@ -3163,15 +3161,15 @@ class PathHierarchyTests: XCTestCase { relationships: [ .init( source: extensionMethodID, - target: extensionSymbolID, + target: extensionID, kind: .memberOf, - targetFallback: "ImportedModule.ExtendedType" + targetFallback: "ExtendedModule.ExtendedType" ), .init( - source: extensionSymbolID, - target: importedTypeID, + source: extensionID, + target: extendedTypeID, kind: .extensionTo, - targetFallback: "ImportedModule.ExtendedType" + targetFallback: "ExtendedModule.ExtendedType" ) ] )) @@ -3180,39 +3178,36 @@ class PathHierarchyTests: XCTestCase { let (_, context) = try await loadBundle(catalog: catalog) let tree = context.linkResolver.localResolver.pathHierarchy - XCTAssertEqual(tree.modules.count, 1) - XCTAssertEqual(tree.modules.first?.name, "MainModule") - - let paths = tree.caseInsensitiveDisambiguatedPaths() - XCTAssertEqual(paths[importedProtocolID], "/MainModule/ImportedModule/BaseProtocol") - XCTAssertEqual(paths[importedTypeID], "/MainModule/ImportedModule/ExtendedType-struct") - XCTAssertEqual( - paths[extensionMethodID], - "/MainModule/ImportedModule/ExtendedType/extensionMethod()" + try assertFindsPath( + "/MainModule/ExtendedModule/ExtendedType/extensionMethod()", + in: tree, + asSymbolID: extensionMethodID ) - // Verify that symbols can be found at their correct paths - try assertFindsPath("/MainModule/ImportedModule/BaseProtocol", in: tree, asSymbolID: importedProtocolID) - try assertFindsPath("/MainModule/ImportedModule/ExtendedType-struct", in: tree, asSymbolID: importedTypeID) try assertFindsPath( - "/MainModule/ImportedModule/ExtendedType/extensionMethod()", + "ExtendedModule/ExtendedType", + in: tree, + asSymbolID: extensionID + ) + try assertFindsPath( + "ExtendedModule/ExtendedType/extensionMethod()", in: tree, asSymbolID: extensionMethodID ) - // Verify that absolute paths to non-existent modules throw moduleNotFound error - // This is the fix being tested: without it, single-module fallback would trigger incorrectly + // Verify that a link that resolves relative to the module + // fails to resolve as an absolute link, with a moduleNotFound error. try assertPathRaisesErrorMessage( - "/ImportedModule/BaseProtocol", + "/ExtendedModule/ExtendedType", in: tree, context: context, - expectedErrorMessage: "No module named 'ImportedModule'" + expectedErrorMessage: "No module named 'ExtendedModule'" ) try assertPathRaisesErrorMessage( - "/ImportedModule/ExtendedType", + "/ExtendedModule/ExtendedType/extensionMethod()", in: tree, context: context, - expectedErrorMessage: "No module named 'ImportedModule'" + expectedErrorMessage: "No module named 'ExtendedModule'" ) }