Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/engine/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ var fixtures = []fixture{
{name: "php_multirepo", subRepos: []string{"provider", "consumer"}},
{name: "go_grpc_multirepo", subRepos: []string{"server", "client"}},
{name: "py_grpc_multirepo", subRepos: []string{"server", "client"}},
// Two different-language repos sharing only nested type names. The linker must
// draw no shared_symbols edge between them; see GAP-LK-03.
{name: "kotlin_swift_multirepo", subRepos: []string{"android", "ios"}},
}

func TestGolden(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions internal/engine/testdata/golden/kotlin_swift_multirepo.facts.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{"kind":"dependency","name":"Sources/Core -\u003e Foundation","file":"ios/Sources/Core/Analytics.swift","line":1,"repo":"ios","props":{"language":"swift","source":"stdlib"},"relations":[{"kind":"imports","target":"Foundation"}]}
{"kind":"module","name":"./Sources/Core","file":"ios/./Sources/Core","repo":"ios","props":{"language":"swift","module_role":"production","spm_package":"GolfJournal","spm_target":"Core"}}
{"kind":"module","name":"Sources/Core","file":"ios/Sources/Core","repo":"ios","props":{"language":"swift","module_role":"unknown"}}
{"kind":"module","name":"app/src/main/kotlin/de/golf/app","file":"android/app/src/main/kotlin/de/golf/app","repo":"android","props":{"language":"kotlin","module_role":"production"}}
{"kind":"service","name":"android","repo":"android","props":{"synthetic":"crossrepo"}}
{"kind":"service","name":"ios","repo":"ios","props":{"synthetic":"crossrepo"}}
{"kind":"symbol","name":"Sources/Core.FullAnalysisDataBuilder","file":"ios/Sources/Core/Analytics.swift","line":32,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public func lastMonth() -\u003e TimeWindow","symbol_kind":"class"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.FullAnalysisDataBuilder.TimeWindow","file":"ios/Sources/Core/Analytics.swift","line":33,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public let startDay: Int\npublic let endDay: Int","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.FullAnalysisDataBuilder.TimeWindow.endDay","file":"ios/Sources/Core/Analytics.swift","line":35,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.FullAnalysisDataBuilder.TimeWindow.startDay","file":"ios/Sources/Core/Analytics.swift","line":34,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.FullAnalysisDataBuilder.lastMonth","file":"ios/Sources/Core/Analytics.swift","line":38,"repo":"ios","props":{"cyclomatic":1,"exported":true,"language":"swift","receiver":"FullAnalysisDataBuilder","symbol_kind":"method"},"relations":[{"kind":"declares","target":"Sources/Core"},{"kind":"instantiates","target":"Sources/Core.TimeWindow"}]}
{"kind":"symbol","name":"Sources/Core.HandicapAnalytics","file":"ios/Sources/Core/Analytics.swift","line":21,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public func differentials(scores: [Double]) -\u003e [DifferentialEntry]","symbol_kind":"class"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.HandicapAnalytics.DifferentialEntry","file":"ios/Sources/Core/Analytics.swift","line":22,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public let score: Double\npublic let courseRating: Double","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.HandicapAnalytics.DifferentialEntry.courseRating","file":"ios/Sources/Core/Analytics.swift","line":24,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.HandicapAnalytics.DifferentialEntry.score","file":"ios/Sources/Core/Analytics.swift","line":23,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.HandicapAnalytics.differentials","file":"ios/Sources/Core/Analytics.swift","line":27,"repo":"ios","props":{"cyclomatic":2,"exported":true,"language":"swift","loop_count":1,"loop_depth":1,"receiver":"HandicapAnalytics","symbol_kind":"method"},"relations":[{"kind":"declares","target":"Sources/Core"},{"kind":"instantiates","target":"Sources/Core.DifferentialEntry"}]}
{"kind":"symbol","name":"Sources/Core.RegisterUseCase","file":"ios/Sources/Core/Analytics.swift","line":7,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public func validate(email: String) -\u003e ValidationError?","symbol_kind":"class"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.RegisterUseCase.ValidationError","file":"ios/Sources/Core/Analytics.swift","line":8,"repo":"ios","props":{"exported":true,"language":"swift","signature":"public let field: String\npublic let reason: String","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.RegisterUseCase.ValidationError.field","file":"ios/Sources/Core/Analytics.swift","line":9,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.RegisterUseCase.ValidationError.reason","file":"ios/Sources/Core/Analytics.swift","line":10,"repo":"ios","props":{"exported":true,"language":"swift","symbol_kind":"constant"},"relations":[{"kind":"declares","target":"Sources/Core"}]}
{"kind":"symbol","name":"Sources/Core.RegisterUseCase.validate","file":"ios/Sources/Core/Analytics.swift","line":13,"repo":"ios","props":{"cyclomatic":2,"exported":true,"language":"swift","receiver":"RegisterUseCase","symbol_kind":"method"},"relations":[{"kind":"declares","target":"Sources/Core"},{"kind":"instantiates","target":"Sources/Core.ValidationError"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.FullAnalysisDataBuilder","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":27,"repo":"android","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.FullAnalysisDataBuilder.TimeWindow","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":28,"repo":"android","props":{"data_class":true,"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.FullAnalysisDataBuilder.lastMonth","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":30,"repo":"android","props":{"cyclomatic":1,"exported":true,"language":"kotlin","receiver":"FullAnalysisDataBuilder","symbol_kind":"method"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"TimeWindow"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":19,"repo":"android","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics.DifferentialEntry","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":20,"repo":"android","props":{"data_class":true,"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.HandicapAnalytics.differentials","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":22,"repo":"android","props":{"cyclomatic":2,"exported":true,"language":"kotlin","loop_count":1,"loop_depth":1,"receiver":"HandicapAnalytics","symbol_kind":"method"},"relations":[{"kind":"calls","target":"map"},{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"DifferentialEntry"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":8,"repo":"android","props":{"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase.ValidationError","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":9,"repo":"android","props":{"data_class":true,"exported":true,"language":"kotlin","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"}]}
{"kind":"symbol","name":"app/src/main/kotlin/de/golf/app.RegisterUseCase.validate","file":"android/app/src/main/kotlin/de/golf/app/Analytics.kt","line":11,"repo":"android","props":{"cyclomatic":2,"exported":true,"language":"kotlin","receiver":"RegisterUseCase","symbol_kind":"method"},"relations":[{"kind":"calls","target":"contains"},{"kind":"declares","target":"app/src/main/kotlin/de/golf/app"},{"kind":"instantiates","target":"ValidationError"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
plugins {
kotlin("android")
}

android {
namespace = "de.golf.app"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package de.golf.app

// Three nested types whose names are shared, by coincidence of domain vocabulary,
// with the Swift app in ../ios. The two repos share no source: each is an
// independent client of the same backend. No cross-repo edge may be drawn between
// them on the strength of these names alone.

class RegisterUseCase {
data class ValidationError(val field: String, val reason: String)

fun validate(email: String): ValidationError? {
if (!email.contains("@")) {
return ValidationError("email", "missing @")
}
return null
}
}

class HandicapAnalytics {
data class DifferentialEntry(val score: Double, val courseRating: Double)

fun differentials(scores: List<Double>): List<DifferentialEntry> {
return scores.map { DifferentialEntry(it, 72.0) }
}
}

class FullAnalysisDataBuilder {
data class TimeWindow(val startDay: Int, val endDay: Int)

fun lastMonth(): TimeWindow = TimeWindow(0, 30)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
plugins {
kotlin("android") version "1.9.0" apply false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
rootProject.name = "golf-journal-android"

include(":app")
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "GolfJournal",
products: [
.library(name: "Core", targets: ["Core"])
],
targets: [
.target(name: "Core")
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

// The Swift half of the parallel-app pair. The nested type names below match the
// Kotlin app in ../android because both model the same product, not because either
// repo includes the other's source.

public class RegisterUseCase {
public struct ValidationError {
public let field: String
public let reason: String
}

public func validate(email: String) -> ValidationError? {
if !email.contains("@") {
return ValidationError(field: "email", reason: "missing @")
}
return nil
}
}

public class HandicapAnalytics {
public struct DifferentialEntry {
public let score: Double
public let courseRating: Double
}

public func differentials(scores: [Double]) -> [DifferentialEntry] {
return scores.map { DifferentialEntry(score: $0, courseRating: 72.0) }
}
}

public class FullAnalysisDataBuilder {
public struct TimeWindow {
public let startDay: Int
public let endDay: Int
}

public func lastMonth() -> TimeWindow {
return TimeWindow(startDay: 0, endDay: 30)
}
}
39 changes: 23 additions & 16 deletions internal/linkers/crossrepo/crossrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,19 +665,20 @@ func linkSharedSymbols(all []facts.Fact, edges map[string]*edge) {

// For each identity shared by 2+ repos, record it against every repo pair — but
// only when the shared identity is a trustworthy coupling signal for that pair.
// A namespace-qualified identity (contains "::"/".", the mark of vendored/shared
// source) always counts, language-independent. A bare unqualified name counts
// only between same-language repos: two apps written in different languages
// sharing a plain domain type name (e.g. Kotlin and Swift both declaring
// "LoginViewModel") is parallel modeling of the same product, not shared code,
// and must not fabricate a dependency.
// A namespace-qualified identity (contains "::", the mark of vendored/shared
// source) always counts, language-independent. Any other name — bare, or dotted
// because it names a nested type — counts only between same-language repos: two
// apps written in different languages sharing a plain domain type name (e.g.
// Kotlin and Swift both declaring "LoginViewModel", or both nesting
// "RegisterUseCase.ValidationError") is parallel modeling of the same product,
// not shared code, and must not fabricate a dependency.
// pairShared["a\x00b"] (a<b) -> set of shared identities.
pairShared := map[string]map[string]bool{}
for id, repos := range idToRepos {
if len(repos) < 2 {
continue
}
qualified := isQualifiedIdentity(id)
qualified := isNamespaceQualified(id)
rs := make([]string, 0, len(repos))
for r := range repos {
rs = append(rs, r)
Expand Down Expand Up @@ -762,13 +763,14 @@ func typeIdentity(name string, modules []string) string {
}

// isDistinctiveIdentity filters out identities too generic to safely link on. A
// namespaced identity (containing "::" or ".") is always kept; an unqualified one
// is kept only if it is reasonably long and not a common generic type name.
// namespaced identity (containing "::") is always kept; anything else — including a
// dotted nested type name — is kept only if it is reasonably long and not a common
// generic type name.
func isDistinctiveIdentity(id string) bool {
if id == "" {
return false
}
if isQualifiedIdentity(id) {
if isNamespaceQualified(id) {
return true
}
if len(id) < 5 {
Expand All @@ -777,12 +779,17 @@ func isDistinctiveIdentity(id string) bool {
return !genericTypeNames[strings.ToLower(id)]
}

// isQualifiedIdentity reports whether a type identity is namespace-qualified
// (contains "::" or "."). A qualified identity shared across repos is a strong
// vendored/shared-source signal, independent of language; an unqualified one is a
// bare type name that two repos may coincidentally share.
func isQualifiedIdentity(id string) bool {
return strings.Contains(id, "::") || strings.Contains(id, ".")
// isNamespaceQualified reports whether a type identity carries a namespace (only
// "::" does). A namespaced identity shared across repos is a strong vendored/
// shared-source signal, independent of language; an unqualified one is a bare type
// name that two repos may coincidentally share.
//
// A "." does NOT qualify. typeIdentity has already stripped the module prefix, so
// any dot left in the identity is type *nesting* — "Outer.Inner", which Kotlin and
// Swift both emit for a nested declaration — not a namespace. Treating it as one
// let two parallel apps sharing only a domain vocabulary fabricate a dependency.
func isNamespaceQualified(id string) bool {
return strings.Contains(id, "::")
}

// primaryLanguageByRepo returns each repo's dominant source language (the most
Expand Down
40 changes: 40 additions & 0 deletions internal/linkers/crossrepo/crossrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,46 @@ func TestComputeLinks_SharedSymbolsQualifiedCrossLanguageLinks(t *testing.T) {
}
}

// TestComputeLinks_SharedSymbolsCrossLanguageNestedTypesSkipped covers the parallel-
// app false positive that survives module stripping: Kotlin and Swift both name a
// nested type "Outer.Inner", so the residual dot is nesting, not a namespace. A
// Kotlin and a Swift app sharing no source must not link on it.
func TestComputeLinks_SharedSymbolsCrossLanguageNestedTypesSkipped(t *testing.T) {
in := []facts.Fact{
module("android", "app"),
typeSymLang("android", "app.RegisterUseCase.ValidationError", facts.SymbolClass, "kotlin"),
typeSymLang("android", "app.HandicapAnalytics.DifferentialEntry", facts.SymbolClass, "kotlin"),
typeSymLang("android", "app.FullAnalysisDataBuilder.TimeWindow", facts.SymbolClass, "kotlin"),
module("ios", "Sources"),
typeSymLang("ios", "Sources.RegisterUseCase.ValidationError", facts.SymbolClass, "swift"),
typeSymLang("ios", "Sources.HandicapAnalytics.DifferentialEntry", facts.SymbolClass, "swift"),
typeSymLang("ios", "Sources.FullAnalysisDataBuilder.TimeWindow", facts.SymbolClass, "swift"),
}
if edges := crossRepoEdges(ComputeLinks(in)); len(edges) != 0 {
t.Errorf("nested types shared across languages must not link: %+v", edges)
}
}

// TestComputeLinks_SharedSymbolsSameLanguageNestedTypesLink confirms the nested-type
// fix does not over-reach. Once a residual dot no longer counts as a namespace, a
// nested identity is unqualified and so falls under the same-language guard —
// between two same-language repos it must still link (genuine copied source).
func TestComputeLinks_SharedSymbolsSameLanguageNestedTypesLink(t *testing.T) {
in := []facts.Fact{
module("app-a", "app"),
typeSymLang("app-a", "app.RegisterUseCase.ValidationError", facts.SymbolClass, "kotlin"),
typeSymLang("app-a", "app.HandicapAnalytics.DifferentialEntry", facts.SymbolClass, "kotlin"),
typeSymLang("app-a", "app.FullAnalysisDataBuilder.TimeWindow", facts.SymbolClass, "kotlin"),
module("app-b", "lib"),
typeSymLang("app-b", "lib.RegisterUseCase.ValidationError", facts.SymbolClass, "kotlin"),
typeSymLang("app-b", "lib.HandicapAnalytics.DifferentialEntry", facts.SymbolClass, "kotlin"),
typeSymLang("app-b", "lib.FullAnalysisDataBuilder.TimeWindow", facts.SymbolClass, "kotlin"),
}
if findEdge(ComputeLinks(in), "app-a", "app-b") == nil {
t.Errorf("same-language repos sharing distinctive nested types should still link")
}
}

// --- via: http-client tag and confidence ---

func TestComputeLinks_HTTPClientViaTag(t *testing.T) {
Expand Down
Loading