diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 5b11e77c3..c4098a56f 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -118,7 +118,9 @@ "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a." }, "swift": { - "fingerprint": "c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8", + "fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3", + "_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.", + "_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", diff --git a/gitnexus/bench/swift-package-imports/baselines.json b/gitnexus/bench/swift-package-imports/baselines.json index 2163b9f72..d52e9da11 100644 --- a/gitnexus/bench/swift-package-imports/baselines.json +++ b/gitnexus/bench/swift-package-imports/baselines.json @@ -15,13 +15,13 @@ "reexport_extra": 1, "nested_repeat_resolved": 1, "first_wins": 1, - "_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only.", + "_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only — under #3355 root-anchored grouping because it sits under Sources/Mod0, not because Mod0 is listed first.", "parse_targets": 3, "parse_binary_skipped": 1, "url_comment_targets": 1, "parse_complete": 1, - "_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is Models+App+AppTests. parse_binary_skipped=1 means Lib/Gen/CFoo never entered the map. url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.", + "_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is the importable source targets Models+App+AppTests. parse_binary_skipped=1 means Lib/CFoo never entered the map and Gen is a non-importable plugin module at Plugins/Gen (#3355: plugins compile as their own module, so grouping needs them; before #3355 Gen was skipped too). url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.", "layout_fingerprint": "2b220baa1df63778489949f9596879ba1c89a49e62c57bd9d2336fd8d74c17bd", "_layout_fingerprint_note": "sha256 over sorted from|target->files rows on the unique query set. A change here is a BEHAVIOUR change — the resolver returned a different target set. Explain it, never re-baseline it alone.", diff --git a/gitnexus/bench/swift-package-imports/measure.mjs b/gitnexus/bench/swift-package-imports/measure.mjs index 6d41728b6..efaed2ddd 100644 --- a/gitnexus/bench/swift-package-imports/measure.mjs +++ b/gitnexus/bench/swift-package-imports/measure.mjs @@ -23,13 +23,15 @@ * and an in-repo `CoreUI` folder that is NOT in Package.swift stay * external. An empty `declaredTargets` map fails every name closed. Import * of a module that `@_exported import`s another unions that module's files. - * A `#2931` nested `Sources/Mod0` path still belongs to Mod0. Grouping - * assigns a two-prefix file to the FIRST target only. + * A `#2931` nested `Sources/Mod0` path still belongs to Mod0 for import + * resolve. Grouping anchors target paths at the repo root (#3355), so a + * two-prefix file joins the target it sits under, not a later match. * * - `parse_targets` / `parse_binary_skipped` / `url_comment_targets` / - * `parse_complete` — EXACT. Source factories are kept; binary/plugin - * factories are not modules; `https://` on the same line does not hide a - * later `.target`. + * `parse_complete` — EXACT. Source factories are kept; binary and + * system-library factories are not modules; a plugin is a non-importable + * module under `Plugins/` (#3355); `https://` on the same line does not hide + * a later `.target`. * * - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files` * rows on the unique query set. Catches a target-set change that leaves @@ -54,7 +56,7 @@ import { performance } from 'node:perf_hooks'; import { parseSwiftPackageManifest } from '../../src/core/ingestion/language-config.ts'; import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.ts'; import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts'; -import { groupSwiftFilesBySpmTarget } from '../../src/core/ingestion/languages/swift/target-grouping.ts'; +import { groupSwiftFilesByModule } from '../../src/core/ingestion/languages/swift/target-grouping.ts'; const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8')); @@ -266,7 +268,9 @@ function correctness(corpus) { ); const emptyDeclaredExternal = empty == null ? 1 : 0; - const groups = groupSwiftFilesBySpmTarget(corpus.files, (p) => p, corpus.targets); + // Anchored at the repo root: Clash.swift is under Sources/Mod0; the + // Sources/Mod1 further down its path is a vendored copy, not Mod1 (#3355). + const groups = groupSwiftFilesByModule(corpus.files, (p) => p, { targets: corpus.targets }); const firstWins = groups.get('Mod0')?.includes(corpus.extras.clash) === true && groups.get('Mod1')?.includes(corpus.extras.clash) !== true @@ -299,13 +303,16 @@ let package = Package( reexportExtra, nestedRepeatResolved, firstWins, - parseTargets: parseFixed.targets.size, + // Importable source targets. Plugins are modules (grouped) but never + // `import`-able (#3355), so they are counted by the arm below instead. + parseTargets: parseFixed.targets.size - parseFixed.plugins.size, parseBinarySkipped: - parseFixed.targets.has('Lib') || - parseFixed.targets.has('Gen') || - parseFixed.targets.has('CFoo') - ? 0 - : 1, + !parseFixed.targets.has('Lib') && + !parseFixed.targets.has('CFoo') && + parseFixed.targets.get('Gen') === 'Plugins/Gen' && + parseFixed.plugins.has('Gen') + ? 1 + : 0, urlCommentTargets: urlComment.targets.get('T') === 'Sources/T' ? 1 : 0, parseComplete: parseFixed.complete && urlComment.complete ? 1 : 0, fingerprint: createHash('sha256').update(records.sort().join('\n')).digest('hex'), diff --git a/gitnexus/src/core/graph/edge-reasons.ts b/gitnexus/src/core/graph/edge-reasons.ts index ad31db850..86d067d24 100644 --- a/gitnexus/src/core/graph/edge-reasons.ts +++ b/gitnexus/src/core/graph/edge-reasons.ts @@ -36,3 +36,16 @@ const HEURISTIC_EDGE_REASONS: ReadonlySet = new Set([GLOBAL_NAME_FALLBAC /** True when this edge's target was guessed by name rather than resolved. */ export const isHeuristicEdgeReason = (reason: string): boolean => HEURISTIC_EDGE_REASONS.has(reason); + +/** + * An IMPORTS edge from a File to the `Module` node of the compiler module it + * belongs to, for languages where every file of a module sees every other + * file's declarations with no `import` (whole-module visibility). + * + * One edge per member file instead of one per ordered file pair: the pairwise + * form is n·(n−1) edges and exhausted V8's Map limit on large modules (#3355). + * Consumers that need "files that see this file" follow the hub: two files are + * co-members when both have a membership edge to the same `Module` node + * (`queryImportersBatch`). + */ +export const MODULE_MEMBERSHIP_REASON = 'module-membership'; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 67e88dd32..1081a5aab 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -22,9 +22,10 @@ * `allFileList` entries, and the per-target file ORDER follows * `allFileList`. * - * Import-config fans a file to every matching declared target. Grouping - * (`groupSwiftFilesBySpmTarget`) is first-target-wins. That divergence - * is intentional. + * Import-config fans a file to every matching declared target. Module + * grouping (`groupSwiftFilesByModule`) anchors target paths at the repo root + * and picks the deepest. That divergence is intentional: this legacy + * strategy only sees the root declaration map. */ import { SupportedLanguages } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 759eee48f..1c8bafa03 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -198,6 +198,44 @@ export interface SwiftPackageConfig { * binary-only Package.swift does not collapse every file into `__default__`. */ declaredTargets?: Map; + /** + * Every Swift module the workspace loader found (root and nested SwiftPM + * targets, Xcode native targets). When present it is the authority for + * module membership and `import` resolution; `targets` stays for callers + * that only know the SwiftPM map. + */ + modules?: readonly SwiftModuleSpec[]; + /** + * True when every manifest and Xcode project was read completely and no + * module was inferred from folder names, so a name missing from `modules` + * is an external module (SDK or dependency), not an unread local one. + */ + moduleNamesComplete?: boolean; + /** `sources:` / `exclude:` per target name, relative to the target directory. */ + targetFilters?: Map; +} + +/** One Swift module (compiler unit) found in the workspace. */ +export interface SwiftModuleSpec { + /** Stable key: a repo-relative SwiftPM target directory, or `xcode::`. */ + readonly key: string; + /** Module name as written in `import X`. */ + readonly name: string; + /** SwiftPM: repo-relative target directory ('' is the repo root). */ + readonly dir?: string; + /** Xcode: repo-relative member files. */ + readonly files?: readonly string[]; + /** Xcode: repo-relative synchronized folders; files below are members. */ + readonly folders?: readonly string[]; + /** + * Repo-relative paths this module leaves out: Xcode synchronized-folder + * exceptions, SwiftPM `exclude:`. + */ + readonly excluded?: readonly string[]; + /** SwiftPM `sources:`: repo-relative files or directories; members must be under one. */ + readonly sources?: readonly string[]; + /** False for SwiftPM plugins: modules, but never `import`-able. */ + readonly importable: boolean; } /** @@ -662,8 +700,15 @@ async function collectDeclaredNamespaces( return structure.incomplete ? 'truncated' : 'ok'; } -const SWIFT_SOURCE_FACTORY_NAMES = ['target', 'executableTarget', 'testTarget', 'macro'] as const; -const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'plugin', 'systemLibrary'] as const; +const SWIFT_SOURCE_FACTORY_NAMES = [ + 'target', + 'executableTarget', + 'testTarget', + 'macro', + 'plugin', +] as const; +/** No Swift sources: a prebuilt artifact or a C module map. */ +const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'systemLibrary'] as const; const SWIFT_SKIP_FACTORIES = new Set(SWIFT_SKIP_FACTORY_NAMES); const SWIFT_FACTORY_RE = new RegExp( `\\.(${[...SWIFT_SOURCE_FACTORY_NAMES, ...SWIFT_SKIP_FACTORY_NAMES].join('|')})\\s*\\(`, @@ -769,10 +814,11 @@ function skipSwiftWsAndComments(source: string, start: number): number | null { } /** First `name:` / `path:` string outside comments. Escapes and interpolations are unreadable. */ -function readSwiftFactoryField( +/** Where `field:`'s value starts in a factory block, or null when absent. */ +function findSwiftFactoryFieldValue( block: string, - field: 'name' | 'path', -): { value: string | undefined; keyPresent: boolean } { + field: string, +): { valueAt: number | null } | null { let inString: '"' | "'" | null = null; let escape = false; let inLineComment = false; @@ -832,15 +878,52 @@ function readSwiftFactoryField( i = j - 1; continue; } - const valueAt = skipSwiftWsAndComments(block, colonAt + 1); - if (valueAt === null) return { value: undefined, keyPresent: true }; - const quote = block[valueAt]; - if (quote !== '"' && quote !== "'") return { value: undefined, keyPresent: true }; - const parsed = readSwiftSimpleQuotedString(block, valueAt); - if (parsed === null) return { value: undefined, keyPresent: true }; - return { value: parsed, keyPresent: true }; + return { valueAt: skipSwiftWsAndComments(block, colonAt + 1) }; + } + return null; +} + +function readSwiftFactoryField( + block: string, + field: 'name' | 'path', +): { value: string | undefined; keyPresent: boolean } { + const found = findSwiftFactoryFieldValue(block, field); + if (found === null) return { value: undefined, keyPresent: false }; + const { valueAt } = found; + if (valueAt === null) return { value: undefined, keyPresent: true }; + const quote = block[valueAt]; + if (quote !== '"' && quote !== "'") return { value: undefined, keyPresent: true }; + const parsed = readSwiftSimpleQuotedString(block, valueAt); + return { value: parsed ?? undefined, keyPresent: true }; +} + +/** + * `sources:` / `exclude:` as a list of string literals. `undefined` when the + * key is absent; `null` when present but not a plain literal list. + */ +function readSwiftFactoryStringList( + block: string, + field: 'sources' | 'exclude', +): string[] | null | undefined { + const found = findSwiftFactoryFieldValue(block, field); + if (found === null) return undefined; + let i = found.valueAt; + if (i === null || block[i] !== '[') return null; + const out: string[] = []; + for (;;) { + const at = skipSwiftWsAndComments(block, i + 1); + if (at === null) return null; + if (block[at] === ']') return out; + if (block[at] !== '"' && block[at] !== "'") return null; + const value = readSwiftSimpleQuotedString(block, at); + if (value === null) return null; + out.push(value); + const after = skipSwiftWsAndComments(block, at + value.length + 2); + if (after === null) return null; + if (block[after] === ']') return out; + if (block[after] !== ',') return null; + i = after; } - return { value: undefined, keyPresent: false }; } /** Quoted literal with no escapes. Any `\` (including `\u{…}` and `\(`) is unreadable. */ @@ -999,14 +1082,39 @@ function swiftPathIsUnreadable(customPath: string | undefined, hasPathKey: boole return customPath === undefined || customPath === '' || customPath.includes('\\('); } -/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */ -export function parseSwiftPackageManifest(source: string): { +/** Where SwiftPM looks for a target that declares no `path:`. */ +export type SwiftTargetDirKind = 'source' | 'test' | 'plugin'; + +export interface SwiftManifestParse { + /** Target name -> package-relative directory. */ targets: Map; + /** + * Targets with no `path:`. Their directory above is the conventional + * default; `loadSwiftPackageConfig` replaces it with the directory SwiftPM + * would actually pick (`Sources`, `Source`, `src`, or `srcs`). + */ + implicitDirs: Map; + /** Plugin targets: modules for grouping, never `import`-able. */ + plugins: Set; + /** `sources:` / `exclude:` per target name, relative to the target directory. */ + filters: Map; complete: boolean; -} { +} + +/** A target's `sources:` / `exclude:` lists; absent means "no filter". */ +export interface SwiftTargetFilter { + readonly sources?: readonly string[]; + readonly exclude?: readonly string[]; +} + +/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */ +export function parseSwiftPackageManifest(source: string): SwiftManifestParse { const targets = new Map(); + const implicitDirs = new Map(); + const plugins = new Set(); + const filters = new Map(); if (swiftManifestHasCompletenessHazard(source)) { - return { targets, complete: false }; + return { targets, implicitDirs, plugins, filters, complete: false }; } const packageTargets = inspectSwiftPackageTargets(source); @@ -1053,23 +1161,61 @@ export function parseSwiftPackageManifest(source: string): { sawUnreadableFactory = true; continue; } - const dir = customPath ?? (kind === 'testTarget' ? `Tests/${name}` : `Sources/${name}`); + const sources = readSwiftFactoryStringList(block, 'sources'); + const exclude = readSwiftFactoryStringList(block, 'exclude'); + if (sources === null || exclude === null) { + sawUnreadableFactory = true; + continue; + } + if (sources !== undefined || exclude !== undefined) { + filters.set(name, { + ...(sources !== undefined ? { sources } : {}), + ...(exclude !== undefined ? { exclude } : {}), + }); + } + const dirKind: SwiftTargetDirKind = + kind === 'testTarget' ? 'test' : kind === 'plugin' ? 'plugin' : 'source'; + if (dirKind === 'plugin') plugins.add(name); + const dir = customPath ?? `${SWIFT_DEFAULT_TARGET_PARENT[dirKind]}/${name}`; const existing = targets.get(name); if (existing === undefined) { targets.set(name, dir); - } else if (customPath !== undefined && existing === `Sources/${name}`) { + if (customPath === undefined) implicitDirs.set(name, dirKind); + } else if (customPath !== undefined && implicitDirs.has(name)) { // A later `.target(name:path:)` wins over an earlier same-name // factory that only implied the default path. targets.set(name, customPath); + implicitDirs.delete(name); } } return { targets, + implicitDirs, + plugins, + filters, complete: !sawUnreadableFactory && !packageTargets.helperBuilt, }; } +/** Conventional parent of a target with no `path:`, before disk lookup. */ +const SWIFT_DEFAULT_TARGET_PARENT: Readonly> = { + source: 'Sources', + test: 'Tests', + plugin: 'Plugins', +}; + +/** + * SwiftPM's predefined parents, in its search order (`PackageBuilder`): + * sources in `Sources`, `Source`, `src`, `srcs`; tests in `Tests` first, then + * the source parents; plugins only in `Plugins`. + */ +const SWIFT_PREDEFINED_TARGET_PARENTS: Readonly> = { + source: ['Sources', 'Source', 'src', 'srcs'], + test: ['Tests', 'Sources', 'Source', 'src', 'srcs'], + plugin: ['Plugins'], +}; + interface SwiftPackageTargetsInspection { helperBuilt: boolean; sawPackage: boolean; @@ -1422,15 +1568,19 @@ function matchSwiftSquare(source: string, openIndex: number): number | null { return null; } -async function inferSwiftDirectoryTargets(repoRoot: string): Promise> { +async function inferSwiftDirectoryTargets( + repoRoot: string, + packageDir = '', +): Promise> { const targets = new Map(); - const sourceDirs = ['Sources', 'Package/Sources', 'src']; + const sourceDirs = ['Sources', 'Source', 'Package/Sources', 'src', 'srcs']; for (const sourceDir of sourceDirs) { try { - const fullPath = path.join(repoRoot, sourceDir); + const fullPath = path.join(repoRoot, packageDir, sourceDir); const entries = await fs.readdir(fullPath, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { + // First parent in SwiftPM's search order wins a repeated name. + if (entry.isDirectory() && !targets.has(entry.name)) { targets.set(entry.name, sourceDir + '/' + entry.name); } } @@ -1441,33 +1591,52 @@ async function inferSwiftDirectoryTargets(repoRoot: string): Promise { +/** + * Load the SwiftPM config of the package at `packageDir` (repo root when + * omitted). Target directories stay package-relative; see + * {@link loadSwiftWorkspaceConfig} for the repo-relative merge. + */ +export async function loadSwiftPackageConfig( + repoRoot: string, + packageDir = '', +): Promise { + const pkgRoot = path.join(repoRoot, packageDir); try { - const source = await fs.readFile(path.join(repoRoot, 'Package.swift'), 'utf-8'); + const source = await fs.readFile( + path.join(pkgRoot, await pickSwiftManifestFile(pkgRoot)), + 'utf-8', + ); const parsed = parseSwiftPackageManifest(source); if (parsed.complete) { if (isDev) { logger.info(`📦 Loaded ${parsed.targets.size} Swift package targets from Package.swift`); } + const parents = new Map(); + for (const [name, kind] of parsed.implicitDirs) { + if (!parents.has(kind)) parents.set(kind, await findSwiftPredefinedParent(pkgRoot, kind)); + const parent = parents.get(kind); + if (parent != null) parsed.targets.set(name, `${parent}/${name}`); + } + // Plugins are modules (grouping) but never `import`-able. + const declaredTargets = new Map( + [...parsed.targets].filter(([name]) => !parsed.plugins.has(name)), + ); if (parsed.targets.size > 0) { return { targets: parsed.targets, origin: 'package.swift', - declaredTargets: parsed.targets, + declaredTargets, + ...(parsed.filters.size > 0 ? { targetFilters: parsed.filters } : {}), }; } - const inferred = await inferSwiftDirectoryTargets(repoRoot); - return { - targets: inferred, - origin: 'package.swift', - declaredTargets: parsed.targets, - }; + const inferred = await inferSwiftDirectoryTargets(repoRoot, packageDir); + return { targets: inferred, origin: 'package.swift', declaredTargets }; } } catch { // Missing or unreadable — fall through to inferred folders. } - const inferred = await inferSwiftDirectoryTargets(repoRoot); + const inferred = await inferSwiftDirectoryTargets(repoRoot, packageDir); if (inferred.size > 0) { if (isDev) { logger.info(`📦 Inferred ${inferred.size} Swift source folders`); @@ -1477,6 +1646,62 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { + let best = 'Package.swift'; + let bestVersion: readonly number[] = []; + let names: string[]; + try { + names = await fs.readdir(pkgRoot); + } catch { + return best; + } + for (const name of names) { + const m = SWIFT_VERSIONED_MANIFEST_RE.exec(name); + if (m === null) continue; + const version = [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)]; + if (compareVersions(version, bestVersion) > 0) { + best = name; + bestVersion = version; + } + } + return best; +} + +function compareVersions(a: readonly number[], b: readonly number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (a[i] ?? -1) - (b[i] ?? -1); + if (d !== 0) return d; + } + return 0; +} + +/** + * SwiftPM's parent directory for targets of `kind` in `pkgRoot`: the first + * predefined parent that exists, chosen once per package (a target missing + * from it is a manifest error in SwiftPM, not a fallthrough). Null when none + * exists. + */ +async function findSwiftPredefinedParent( + pkgRoot: string, + kind: SwiftTargetDirKind, +): Promise { + for (const parent of SWIFT_PREDEFINED_TARGET_PARENTS[kind]) { + try { + if ((await fs.stat(path.join(pkgRoot, parent))).isDirectory()) return parent; + } catch { + // Not there — try the next predefined parent. + } + } + return null; +} + /** * Load the Zig build configuration a repo's `build.zig.zon` + root `build.zig` * declare: `.path` deps (and the roots their own build.zig names) from the @@ -1800,7 +2025,7 @@ async function findZigPackageDirs(repoRoot: string): Promise { * nested-package branch exists to do; `normalizeZigDepPath` rejects the ones * that still escape the ROOT after rebasing. */ -function isAbsoluteZigDepPath(depPath: string): boolean { +export function isAbsoluteZigDepPath(depPath: string): boolean { const normalized = depPath.replace(/\\/g, '/'); return normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized); } diff --git a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts index b11f416cf..ea7fa0fb4 100644 --- a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts +++ b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts @@ -1,42 +1,48 @@ /** - * Swift same-module implicit IMPORTS-edge emission for the - * `emitImplicitImportEdges` hook. + * Swift same-module implicit IMPORTS for the `emitImplicitImportEdges` hook. * - * Swift gives every file in a module (an SPM target) visibility of every - * other file's top-level declarations WITHOUT any `import` statement - * (whole-module visibility). The legacy DAG models this with File→File - * IMPORTS edges via `wireSwiftImplicitImports`; under registry-primary - * that wirer's `addImportEdge` is gated off, and the scope-resolution - * import pipeline (`emitImportEdges`) only materializes edges from - * finalized `ImportEdge`s — of which there are none here, because there - * is no syntactic `import`. This hook emits the missing edges directly. + * Swift gives every file in a module visibility of every other file's + * top-level declarations WITHOUT any `import` statement (whole-module + * visibility). There is no syntactic `import`, so the finalized-ImportEdge + * pipeline (`emitImportEdges`) has nothing to materialize; this hook records + * module membership directly. * - * Module identity: Swift has no in-source `package X` marker. Module - * membership is the SPM target *subtree* (`Sources//…`), threaded - * in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`) - * and grouped by `groupSwiftFilesBySpmTarget`. The helper preserves the - * legacy `groupSwiftFilesByTarget` bucketing contract for ordinary layouts - * while intentionally fixing #2931's repeated-prefix edge case. With no - * target map (no Package.swift and no scanned `Sources/*`) the map is null - * and all files form one `__default__` module (single-Xcode-project - * assumption). An inferred folder map must keep sibling folders isolated. - * Every pair of distinct `.swift` - * files in the same module gets a directed IMPORTS edge in both directions - * (whole-module visibility is symmetric). + * Representation: one `Module` node per Swift module with at least two member + * files, and one File → Module IMPORTS edge per member, reason + * `MODULE_MEMBERSHIP_REASON`. This is how the compiler sees it — a file + * imports its own module — and it is linear. The earlier File → File form + * wrote n·(n−1) edges per module and exhausted V8's 2^24 Map limit on a 5,200 + * file module (#3355). "Files that see file B" is now "files with a + * membership edge to a Module that B also has one to"; incremental importer + * expansion follows that rule (`queryImportersBatch`). * - * Node identity + edge construction mirror the generic `emitImportEdges` - * convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)` - * for endpoints and `generateId('IMPORTS', key)` for the relationship id, - * deduped by `(sourceFile -> targetFile)`. Re-invocation idempotency comes - * from `graph.addRelationship` id-dedup (the same `IMPORTS` id is produced - * for a given ordered pair), so no local `seen` set is needed. + * A file compiled into several Xcode targets gets an edge to each module. + * + * The Module node's `filePath` is the target directory (SwiftPM), the + * `.xcodeproj` bundle (Xcode), or '.' (`__default__`). Incremental writeback + * keys rows by `filePath`: a rewritten member file pulls that key into the + * write set across its membership edge, so the Module node and every + * membership edge are deleted and rewritten together. + * + * Idempotent: node and edge ids are derived from the module key and the + * member path, and `graph.addNode` / `graph.addRelationship` dedupe by id. */ -import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { MODULE_MEMBERSHIP_REASON } from '../../../graph/edge-reasons.js'; import { generateId } from '../../../../lib/utils.js'; -import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; +import { + DEFAULT_SWIFT_MODULE, + groupSwiftFilesByModule, + swiftModuleSpecOf, +} from './target-grouping.js'; + +/** Graph id of the `Module` node for a Swift module key. */ +export function swiftModuleNodeId(moduleKey: string): string { + return generateId('Module', `swift:${moduleKey}`); +} export function emitSwiftImplicitImportEdges( graph: KnowledgeGraph, @@ -44,28 +50,44 @@ export function emitSwiftImplicitImportEdges( _nodeLookup: GraphNodeLookup, resolutionConfig?: unknown, ): void { - const targets = coerceSwiftTargets(resolutionConfig); - const filesByTarget = groupSwiftFilesBySpmTarget( + const modules = groupSwiftFilesByModule( parsedFiles, (parsed) => parsed.filePath, - targets, + resolutionConfig, + { allMemberships: true }, ); - for (const [, group] of filesByTarget) { - if (group.length < 2) continue; - for (const source of group) { - for (const dest of group) { - if (source.filePath === dest.filePath) continue; - const dedupKey = `${source.filePath}->${dest.filePath}`; - graph.addRelationship({ - id: generateId('IMPORTS', dedupKey), - sourceId: generateId('File', source.filePath), - targetId: generateId('File', dest.filePath), - type: 'IMPORTS', - confidence: 1.0, - reason: 'swift-scope: implicit module visibility', - }); - } + for (const [moduleKey, members] of modules) { + if (members.length < 2) continue; + const spec = swiftModuleSpecOf(moduleKey, resolutionConfig); + const moduleId = swiftModuleNodeId(moduleKey); + graph.addNode({ + id: moduleId, + label: 'Module', + properties: { + name: spec?.name ?? DEFAULT_SWIFT_MODULE, + filePath: moduleFilePath(moduleKey, spec?.dir), + language: SupportedLanguages.Swift, + isExported: spec?.importable ?? false, + }, + }); + for (const member of members) { + graph.addRelationship({ + id: generateId('IMPORTS', `${member.filePath}->${moduleId}`), + sourceId: generateId('File', member.filePath), + targetId: moduleId, + type: 'IMPORTS', + confidence: 1.0, + reason: MODULE_MEMBERSHIP_REASON, + }); } } } + +/** Incremental-writeback key for a module node: see the file header. */ +function moduleFilePath(moduleKey: string, dir: string | undefined): string { + if (dir !== undefined) return dir === '' ? '.' : dir; + if (moduleKey.startsWith('xcode:')) + return moduleKey.slice('xcode:'.length, moduleKey.lastIndexOf(':')); + return '.'; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index 603f2e6f7..85add3209 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -1,10 +1,18 @@ /** * `resolveImportTarget` adapter for the Swift `ScopeResolver`. * - * A Package.swift declaration map (`origin: 'package.swift'`) resolves - * only declared target names. Otherwise refuse well-known SDK module - * names and fall back to the memoized directory-segment index so local - * folder modules still resolve without a manifest. + * With workspace modules (`loadSwiftWorkspaceConfig`), `import X` resolves to + * the files of every importable module named X, as the compiler does. SwiftPM + * rejects duplicate target names within one build graph, so several modules + * named X belong to separate graphs; returning all of them is the sound + * approximation. A name no module carries is external when every manifest and + * project was read completely (`moduleNamesComplete`). + * + * Without workspace modules, a Package.swift declaration map + * (`origin: 'package.swift'`) resolves only declared target names. Otherwise + * refuse well-known SDK module names and fall back to the memoized + * directory-segment index so local folder modules still resolve without a + * manifest. * * Same-module visibility without `import` is `populateSwiftTargetSiblings`. * This adapter only resolves EXPLICIT cross-module `import`s. @@ -12,7 +20,12 @@ import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; -import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js'; +import { + coerceDeclaredSwiftTargets, + swiftDeclaredTargetPrefix, + type SwiftPackageConfig, +} from '../../language-config.js'; +import { swiftModuleKeysOf, swiftModuleSpecOf } from './target-grouping.js'; import { isSwiftSdkModule } from './sdk-modules.js'; export interface SwiftResolveContext { @@ -58,6 +71,43 @@ const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): Swif const SWIFT_DECLARED_INDEX = new WeakMap, SwiftDeclaredFileIndex>(); +const SWIFT_MODULE_NAME_INDEX = new WeakMap< + ReadonlySet, + { readonly config: object; readonly byName: ReadonlyMap } +>(); + +/** Workspace-module config, or null for a hand-built or root-only config. */ +function workspaceModulesConfig(resolutionConfig: unknown): Partial | null { + const config = resolutionConfig as Partial | null | undefined; + return config != null && Array.isArray(config.modules) ? config : null; +} + +function isKnownModuleName(config: Partial, moduleName: string): boolean { + return (config.modules ?? []).some((spec) => spec.name === moduleName); +} + +/** Importable module name → member `.swift` files, memoized per file set. */ +function getModuleFilesByName( + allFilePaths: ReadonlySet, + config: object, +): ReadonlyMap { + const hit = SWIFT_MODULE_NAME_INDEX.get(allFilePaths); + if (hit !== undefined && hit.config === config) return hit.byName; + const byName = new Map(); + for (const raw of allFilePaths) { + if (!raw.endsWith('.swift')) continue; + for (const key of swiftModuleKeysOf(raw, config)) { + const spec = swiftModuleSpecOf(key, config); + if (spec === undefined || !spec.importable) continue; + const bucket = byName.get(spec.name); + if (bucket === undefined) byName.set(spec.name, [raw]); + else bucket.push(raw); + } + } + SWIFT_MODULE_NAME_INDEX.set(allFilePaths, { config, byName }); + return byName; +} + function getDeclaredFilesByName( allFilePaths: ReadonlySet, declared: ReadonlyMap, @@ -136,7 +186,20 @@ function narrowContext(workspaceIndex: WorkspaceIndex): SwiftResolveContext | nu function resolveSwiftModuleFiles(moduleName: string, ctx: SwiftResolveContext): string[] | null { if (moduleName === '') return null; - const declared = coerceDeclaredSwiftTargets(ctx.resolutionConfig); + const workspace = workspaceModulesConfig(ctx.resolutionConfig); + if (workspace !== null) { + const files = getModuleFilesByName(ctx.allFilePaths, workspace).get(moduleName); + if (files !== undefined) { + const out = excludeImporter(files, ctx.fromFile); + return out.length > 0 ? out : null; + } + // Known but non-importable (a plugin), or every manifest was read: external. + if (workspace.moduleNamesComplete === true || isKnownModuleName(workspace, moduleName)) { + return null; + } + } + + const declared = workspace === null ? coerceDeclaredSwiftTargets(ctx.resolutionConfig) : null; if (declared !== null) { if (!declared.has(moduleName)) return null; const files = getDeclaredFilesByName(ctx.allFilePaths, declared).get(moduleName); diff --git a/gitnexus/src/core/ingestion/languages/swift/index.ts b/gitnexus/src/core/ingestion/languages/swift/index.ts index f0bcf5ad6..df4f942e9 100644 --- a/gitnexus/src/core/ingestion/languages/swift/index.ts +++ b/gitnexus/src/core/ingestion/languages/swift/index.ts @@ -36,7 +36,7 @@ export { export { swiftMergeBindings } from './merge-bindings.js'; export { swiftArityCompatibility } from './arity.js'; export { resolveSwiftImportTarget, type SwiftResolveContext } from './import-target.js'; -export { groupSwiftFilesBySpmTarget, coerceSwiftTargets } from './target-grouping.js'; +export { groupSwiftFilesByModule, coerceSwiftTargets } from './target-grouping.js'; export { populateSwiftTargetSiblings } from './target-siblings.js'; export { emitSwiftImplicitImportEdges } from './implicit-imports.js'; export { mirrorSwiftSiblingTypeBindings } from './sibling-type-bindings.js'; diff --git a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts index 916465d94..960ee73d0 100644 --- a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts @@ -9,10 +9,13 @@ * module is reachable only if the caller wrote `import `, and even * then only if the declaration is `public`. * - * A module is approximated by its source directory, the layout every Swift - * package configuration discovers: `Sources//…` (also under `Package/`). - * `src//…` is also recognized by the package configuration loader. - * Files outside these layouts have unknown module identity. + * Module identity is `swiftModuleKeysOf`, the membership every same-module + * pass uses: SwiftPM targets (any default or custom path, including test + * targets) and Xcode targets. A file in `__default__` has unknown identity. + * `@testable import X` and Swift 5.9 `package` access both still require the + * import, so the rule below covers them; access levels are not modeled. + * Without workspace modules (hand-built configs), the module is approximated + * by its `Sources//…` or `src//…` directory. * * The `private` / `fileprivate` half of the rule is NOT implemented, because * neither marker is recoverable from the parse model this hook sees — @@ -23,8 +26,14 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js'; +import { + DEFAULT_SWIFT_MODULE, + swiftModuleKeysOf, + swiftModuleSpecOf, + swiftModuleSpecs, +} from './target-grouping.js'; -/** Keep aligned with loadSwiftPackageConfig: other layouts are unconfigured. */ +/** Path-heuristic layouts, used only when the config carries no modules. */ const SWIFT_TARGET_ROOTS: ReadonlySet = new Set(['Sources', 'src']); /** @@ -49,8 +58,12 @@ function swiftModuleOf(filePath: string): string { export function swiftIsGlobalNameFallbackPlausible(ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly resolutionConfig?: unknown; }): boolean { if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + if (swiftModuleSpecs(ctx.resolutionConfig) !== null) { + return moduleMembershipAllows(ctx.callerParsed, ctx.candidate, ctx.resolutionConfig); + } const callerModule = swiftModuleOf(ctx.callerParsed.filePath); const candidateModule = swiftModuleOf(ctx.candidate.filePath); @@ -65,3 +78,28 @@ export function swiftIsGlobalNameFallbackPlausible(ctx: { } return false; } + +function moduleMembershipAllows( + callerParsed: ParsedFile, + candidate: SymbolDefinition, + resolutionConfig: unknown, +): boolean { + const callerKeys = swiftModuleKeysOf(callerParsed.filePath, resolutionConfig); + const candidateKeys = swiftModuleKeysOf(candidate.filePath, resolutionConfig); + // Same module: whole-module `internal` visibility, no import needed. + if (candidateKeys.some((key) => callerKeys.includes(key))) return true; + // Unknown identity on either side: cannot decide, so allow. + if (callerKeys[0] === DEFAULT_SWIFT_MODULE || candidateKeys[0] === DEFAULT_SWIFT_MODULE) { + return true; + } + const candidateNames = new Set(); + for (const key of candidateKeys) { + const spec = swiftModuleSpecOf(key, resolutionConfig); + if (spec?.importable === true) candidateNames.add(spec.name); + } + for (const imp of callerParsed.parsedImports) { + const moduleName = imp.targetRaw?.split('.')[0]; + if (moduleName !== undefined && candidateNames.has(moduleName)) return true; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index f0475f1cb..0822b8cf8 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -19,13 +19,12 @@ * - **Labeled arguments** narrow by ARITY only (count-primary, labels * soft) — see `arity.ts`. Label-precise dispatch is deferred to the * type-binding layer. - * - **Same-module visibility**: every file in an SPM target sees its - * siblings' top-level defs without an `import`. Modeled via - * `populateSwiftTargetSiblings`, grouped by the SPM target *subtree* - * (`Sources//…`) via `groupSwiftFilesBySpmTarget` fed from the - * `loadResolutionConfig` SPM map, mirroring Go's package siblings. With - * no scanned source dir (no `Sources/`/`Package/Sources/`/`src/`) the - * map is null and all files form one `__default__` module. + * - **Same-module visibility**: every file in a module sees its + * siblings' top-level defs without an `import`. A module is a SwiftPM + * target (root or nested package) or an Xcode native target, found by + * `loadSwiftWorkspaceConfig` and matched by `swiftModuleKeysOf`. Files in + * neither form one `__default__` module. Each module has one shared + * table in the namespace channel (`populateSwiftTargetSiblings`). * - **`super`** is the superclass receiver (`super.method()`); plain * `self` is the instance receiver. Both synthesized in * `receiver-binding.ts`. @@ -39,9 +38,9 @@ * conforming type. * 2. **Cross-module `import` resolution** uses a Package.swift * declaration map when one is present, otherwise the directory-segment - * index minus well-known SDK module names (#2964). Same-target - * visibility (the common case) is SPM-target-subtree grouping via - * `groupSwiftFilesBySpmTarget`, not explicit imports. + * index minus well-known SDK module names (#2964). With workspace + * modules, `import X` resolves to the modules named X. Same-module + * visibility (the common case) is module membership, not imports. * 3. **Operator / subscript overloads** dispatch by name only. * 4. **`@_exported import`** is `ParsedImport` `kind: 'reexport'` and * in-repo modules are closed transitively at resolve time. `public @@ -50,7 +49,7 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; -import { loadSwiftPackageConfig } from '../../language-config.js'; +import { loadSwiftWorkspaceConfig } from './workspace-config.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; @@ -69,7 +68,7 @@ import { type SwiftResolveContext, } from './index.js'; import { stripSwiftTypePreservingDecoration } from './interpret.js'; -import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; +import { groupSwiftFilesByModule } from './target-grouping.js'; import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -79,14 +78,13 @@ const swiftScopeResolver: ScopeResolver = { languageProvider: swiftProvider, importEdgeReason: 'swift-scope: import', - // Load the SPM target map (Sources// subtree mapping) once per - // workspace pass. Threaded through the orchestrator as `resolutionConfig` - // and consumed by the three same-module grouping hooks + // Load the workspace's Swift modules (root and nested SwiftPM targets, + // Xcode targets) once per workspace pass. Threaded through the + // orchestrator as `resolutionConfig` to the same-module hooks // (`emitImplicitImportEdges`, `populateNamespaceSiblings`, - // `mirrorNamespaceTypeBindings`) via `coerceSwiftTargets` so they group by - // the SPM target subtree, not the immediate directory. Mirrors - // `goScopeResolver`'s `loadGoModulePath`. - loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath), + // `mirrorNamespaceTypeBindings`), `import` resolution, and the + // global-name-fallback veto. + loadResolutionConfig: (repoPath: string) => loadSwiftWorkspaceConfig(repoPath), resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { const ws: SwiftResolveContext = { @@ -232,10 +230,13 @@ function populateSwiftExtensionOwners( parsedFiles: readonly ParsedFile[], ctx?: { readonly fileContents: ReadonlyMap; readonly resolutionConfig?: unknown }, ): void { - const filesByTarget = groupSwiftFilesBySpmTarget( + // Every membership: a shared file may extend a type compiled only into a + // later target. Stamping never overwrites an owner, so the first match wins. + const filesByTarget = groupSwiftFilesByModule( parsedFiles, (parsed) => parsed.filePath, - coerceSwiftTargets(ctx?.resolutionConfig), + ctx?.resolutionConfig, + { allMemberships: true }, ); for (const files of filesByTarget.values()) { stampSwiftExtensionOwnersInTarget(files); diff --git a/gitnexus/src/core/ingestion/languages/swift/sibling-type-bindings.ts b/gitnexus/src/core/ingestion/languages/swift/sibling-type-bindings.ts index 4592921ef..4a3164c06 100644 --- a/gitnexus/src/core/ingestion/languages/swift/sibling-type-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/swift/sibling-type-bindings.ts @@ -12,28 +12,28 @@ * to resolve `user.save()` cross-file, App.swift's scope chain must be * able to follow `getUser → User`. The function return-type binding * (`getUser → User`) lives on Models.swift's module scope, so we mirror - * sibling module-scope typeBindings into the importer's module scope — - * the same trick Go uses (`mirrorGoNamespaceTypeBindings`), but Swift has - * no namespace-import edges, so module membership is the SPM target - * subtree (`Sources//…`): threaded in via the SPM target map - * (`resolutionConfig` → `coerceSwiftTargets`) and grouped by - * `groupSwiftFilesBySpmTarget` (replicating legacy `groupSwiftFilesByTarget`; - * no-source-dir → all files form one `__default__` module). + * sibling module-scope typeBindings where the importer can see them. + * + * Representation: one shared table per module in the language-neutral + * `namespaceTypeBindings` channel (`swift-module:`), made visible to each + * member's module scope through `accessibleNamespacesByScope`. The chain + * walkers (`findReceiverTypeBinding`, `followChainPostFinalize`) consult it + * after the file's own scope chain, so a local annotation still wins. The + * earlier form copied every sibling's bindings into every file's scope, + * O(files² × names) (#3355); C# made the same move in #1871. * * Runs after `populateNamespaceSiblings` and before - * `propagateImportedReturnTypes`, so the SCC-ordered propagation pass - * sees the mirrored bindings and chains `user → getUser → User` to the - * terminal class. Each mirrored binding is chain-followed inside its - * source module first so we mirror the terminal type, not an intermediate - * intra-module reference. `Scope.typeBindings` is mutated via the - * sanctioned non-frozen Map cast (Contract Invariant I6). + * `propagateImportedReturnTypes`. Each binding is chain-followed inside its + * source module first so the table holds the terminal type, not an + * intermediate intra-module reference. First declaration wins a name. */ import type { ParsedFile, TypeRef } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js'; import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js'; -import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; +import { groupSwiftFilesByModule } from './target-grouping.js'; +import { grantSwiftModuleAccess, swiftModuleNamespace } from './target-siblings.js'; export function mirrorSwiftSiblingTypeBindings( parsedFiles: readonly ParsedFile[], @@ -42,37 +42,30 @@ export function mirrorSwiftSiblingTypeBindings( resolutionConfig?: unknown, ): void { const moduleScopeByFile = workspaceIndex.moduleScopeByFile; - - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. - const targets = coerceSwiftTargets(resolutionConfig); - const filesByTarget = groupSwiftFilesBySpmTarget( + const namespaceTypes = indexes.namespaceTypeBindings as Map>; + const modules = groupSwiftFilesByModule( parsedFiles, (parsed) => parsed.filePath, - targets, + resolutionConfig, + { allMemberships: true }, ); - for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to mirror from - const files = group.map((parsed) => parsed.filePath); - for (const importerFile of files) { - const importerModule = moduleScopeByFile.get(importerFile); - if (importerModule === undefined) continue; - - for (const sourceFile of files) { - if (sourceFile === importerFile) continue; - const sourceModule = moduleScopeByFile.get(sourceFile); - if (sourceModule === undefined) continue; - - for (const [name, ref] of sourceModule.typeBindings) { - if (name.length === 0) continue; - // A local annotation on the importer must win over a sibling's. - if (importerModule.typeBindings.has(name)) continue; - - const terminal = followChainPostFinalize(ref, sourceModule.id, indexes); - (importerModule.typeBindings as Map).set(name, terminal); - } + for (const [moduleKey, members] of modules) { + if (members.length < 2) continue; // no siblings to mirror from + const namespace = swiftModuleNamespace(moduleKey); + let table = namespaceTypes.get(namespace); + if (table === undefined) { + table = new Map(); + namespaceTypes.set(namespace, table); + } + for (const parsed of members) { + const sourceModule = moduleScopeByFile.get(parsed.filePath); + if (sourceModule === undefined) continue; + for (const [name, ref] of sourceModule.typeBindings) { + if (name.length === 0 || table.has(name)) continue; + table.set(name, followChainPostFinalize(ref, sourceModule.id, indexes)); } } + grantSwiftModuleAccess(members, namespace, indexes); } } diff --git a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts index a0572202c..d13568683 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts @@ -1,92 +1,169 @@ /** - * Swift SPM-target file grouping for the registry-primary same-module - * hooks (`implicit-imports.ts`, `target-siblings.ts`, - * `sibling-type-bindings.ts`). + * Swift module membership for the registry-primary same-module hooks + * (`implicit-imports.ts`, `target-siblings.ts`, `sibling-type-bindings.ts`, + * extension-owner stamping) and for `import` resolution and the + * global-name-fallback veto. * - * A Swift module is an SPM *target* — a directory *subtree* - * (`Sources//…`), not a single immediate directory. Grouping by - * the immediate containing directory (the prior `containingDir` proxy) - * drops cross-directory same-module edges and can mis-resolve a - * constructor call to a wrong same-simple-named type in another target. + * A Swift module is a compiler unit: a SwiftPM target (a directory subtree) + * or an Xcode native target (a file list and synchronized folders). Every + * file of a module sees every other file's `internal` declarations with no + * `import`; nothing crosses a module boundary without one. * - * The SPM target map is threaded in via the `resolutionConfig` channel - * (`loadSwiftPackageConfig` → `resolutionConfig` → these hooks); see - * `scope-resolver.ts` and `scope-resolution/pipeline/run.ts`. + * The module list comes from `loadSwiftWorkspaceConfig` through the + * `resolutionConfig` channel (`modules`). A hand-built `{ targets }` config + * (tests, the root-only loader) is read as SwiftPM targets keyed by name. * - * Path matching is the same segment-boundary rule as import-config - * (`fileMatchesSwiftTargetDir`). Grouping assigns each file to the FIRST - * matching target; import-config fans a file out to every matching target. + * Matching: + * - SwiftPM: the deepest target directory that is a path-prefix of the file, + * anchored at the repo root. A target path is relative to its package, and + * the loader has already rebased it to the repo root, so a directory + * further down the path (a vendored copy of the same layout) is not the + * target. One module per file: SwiftPM rejects overlapping target sources. + * A target's `sources:` / `exclude:` narrow it; a file they leave out + * belongs to no target. + * - Xcode: exact file membership plus synchronized folders, minus the + * folder's exceptions for that target. A file compiled into several + * targets belongs to all of them, including a SwiftPM target whose + * directory it also lies under. + * - A package manifest (`Package.swift`, `Package@swift-X.Y.swift`) is + * compiled on its own against `PackageDescription`: a module of one file. + * - Anything else: a module of one file when discovery read every manifest + * and project (`moduleNamesComplete`), since no module compiles it; + * otherwise the single `__default__` module, because an unread manifest + * or project may be what owns it. */ -import { swiftDeclaredTargetPrefix, type SwiftPackageConfig } from '../../language-config.js'; +import type { SwiftModuleSpec, SwiftPackageConfig } from '../../language-config.js'; export { coerceDeclaredSwiftTargets } from '../../language-config.js'; -const DEFAULT_TARGET = '__default__'; +export const DEFAULT_SWIFT_MODULE = '__default__'; + +const SWIFT_MANIFEST_RE = /(^|\/)Package(@swift-[\d.]+)?\.swift$/; /** - * Group `items` by SPM target subtree: - * - * - `targets` null/empty (no scanned source dir found) → ALL items go to - * a single `__default__` bucket (single-Xcode-project assumption). - * - Otherwise: a file matches a target when its normalized path either - * starts with `/` or contains `//` at a segment - * boundary. Using a segment-aware suffix search matters when an earlier, - * non-boundary occurrence of the same text appears in the path (#2931). - * - Each file is assigned to the FIRST matching target only (one bucket per - * file, no fan-out). - * - Files matching no target fall into the `__default__` bucket. - * - * `targets` is `name → directory` (the `SwiftPackageConfig.targets` map). + * The module name the compiler derives from a target or product name: every + * character that cannot appear in an identifier becomes `_`, and a leading + * digit gets a `_` prefix. SwiftPM (`c99name`) and Xcode + * (`$(PRODUCT_NAME:c99extidentifier)`) both apply it, so `my-lib` is + * `import my_lib`. */ -export function groupSwiftFilesBySpmTarget( +export function swiftC99ModuleName(name: string): string { + const mangled = name.replace(/[^\p{L}\p{M}\p{N}_]/gu, '_'); + return /^\p{N}/u.test(mangled) ? `_${mangled}` : mangled; +} + +/** True when `filePath` is `dir` or lies below it. */ +function isUnder(filePath: string, dir: string): boolean { + return dir === '' || filePath === dir || filePath.startsWith(`${dir}/`); +} + +interface SwiftModuleMatcher { + readonly spmByDir: ReadonlyMap; + readonly xcodeByFile: ReadonlyMap; + readonly xcodeByFolder: ReadonlyMap< + string, + readonly { key: string; excluded: readonly string[] }[] + >; + readonly specByKey: ReadonlyMap; + readonly order: ReadonlyMap; + /** Leftover files are one-file modules (see the file header). */ + readonly leftoversStandalone: boolean; +} + +const MATCHERS = new WeakMap(); + +/** + * The modules a config declares, or null when it carries none. `modules` wins; + * a bare `{ targets }` map is read as SwiftPM targets keyed by name. + */ +export function swiftModuleSpecs(resolutionConfig: unknown): readonly SwiftModuleSpec[] | null { + const config = resolutionConfig as Partial | null | undefined; + if (config == null) return null; + if (Array.isArray(config.modules)) return config.modules; + const targets = coerceSwiftTargets(config); + if (targets === null || targets.size === 0) return null; + return [...targets].map(([name, dir]) => ({ + key: name, + name, + dir: normalizeDir(dir), + importable: true, + })); +} + +/** Module keys `filePath` belongs to, in declaration order; `[__default__]` when none. */ +export function swiftModuleKeysOf(filePath: string, resolutionConfig: unknown): readonly string[] { + const norm = filePath.includes('\\') ? filePath.replace(/\\/g, '/') : filePath; + const standalone = [`file:${norm}`]; + if (SWIFT_MANIFEST_RE.test(norm)) return standalone; + const matcher = matcherFor(resolutionConfig); + if (matcher === null) return DEFAULT_KEYS; + const ancestors = ancestorDirs(norm); + const keys = new Set(); + + for (const dir of ancestors) { + const key = matcher.spmByDir.get(dir); + if (key === undefined) continue; + if (isFilteredIn(norm, matcher.specByKey.get(key)!)) keys.add(key); + break; // The deepest target owns the directory; its filters may leave this file out. + } + + for (const key of matcher.xcodeByFile.get(norm) ?? []) keys.add(key); + for (const dir of ancestors) { + for (const folder of matcher.xcodeByFolder.get(dir) ?? []) { + if (!folder.excluded.some((path) => isUnder(norm, path))) keys.add(folder.key); + } + } + if (keys.size > 0) { + return [...keys].sort((a, b) => matcher.order.get(a)! - matcher.order.get(b)!); + } + return matcher.leftoversStandalone ? standalone : DEFAULT_KEYS; +} + +/** SwiftPM `sources:` / `exclude:` for a file already under the target directory. */ +function isFilteredIn(filePath: string, spec: SwiftModuleSpec): boolean { + if (spec.sources !== undefined && !spec.sources.some((path) => isUnder(filePath, path))) { + return false; + } + return !(spec.excluded ?? []).some((path) => isUnder(filePath, path)); +} + +/** The module a key names; undefined for `__default__` or an unknown key. */ +export function swiftModuleSpecOf( + key: string, + resolutionConfig: unknown, +): SwiftModuleSpec | undefined { + return matcherFor(resolutionConfig)?.specByKey.get(key); +} + +/** + * Group `items` by module. By default each item joins only its FIRST module, + * for passes that must see a file once (extension-owner stamping). With + * `allMemberships`, an item compiled into several Xcode targets joins each. + */ +export function groupSwiftFilesByModule( items: readonly T[], getPath: (item: T) => string, - targets: ReadonlyMap | null, + resolutionConfig: unknown, + options: { readonly allMemberships?: boolean } = {}, ): Map { - // No SPM config -> single target (common for Xcode projects). - if (targets === null || targets.size === 0) { - return new Map([[DEFAULT_TARGET, [...items]]]); - } - - const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({ - name, - prefix: swiftDeclaredTargetPrefix(dir), - })); - const groups = new Map(); - const defaultGroup: T[] = []; - for (const item of items) { - const rawPath = getPath(item); - const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath; - let assigned = false; - for (const { name, prefix } of targetPrefixes) { - if (pathMatchesTargetPrefix(normalized, prefix)) { - let group = groups.get(name); - if (group === undefined) { - group = []; - groups.set(name, group); - } - group.push(item); - assigned = true; - break; // FIRST match only — one bucket per file, no fan-out. + const keys = swiftModuleKeysOf(getPath(item), resolutionConfig); + for (const key of options.allMemberships === true ? keys : keys.slice(0, 1)) { + let group = groups.get(key); + if (group === undefined) { + group = []; + groups.set(key, group); } + group.push(item); } - if (!assigned) defaultGroup.push(item); } - - if (defaultGroup.length > 0) groups.set(DEFAULT_TARGET, defaultGroup); return groups; } /** - * Duck-type the opaque `resolutionConfig` (loaded by - * `loadSwiftPackageConfig` and threaded through the orchestrator) into the - * SPM `targets` map, or `null` when no Swift package config is present. - * - * Uses structural duck-typing (no `instanceof`) because the value crosses - * the `unknown`-typed `resolutionConfig` channel and may be `null`, - * `undefined`, or a config object whose `targets` is a `Map`. + * Duck-type the opaque `resolutionConfig` into the SwiftPM `targets` map, or + * `null` when no Swift package config is present. */ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap | null { const config = resolutionConfig as Partial | null | undefined; @@ -96,12 +173,66 @@ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap(); + const xcodeByFile = new Map(); + const xcodeByFolder = new Map(); + const specByKey = new Map(); + const order = new Map(); + for (const spec of specs) { + if (specByKey.has(spec.key)) continue; + specByKey.set(spec.key, spec); + order.set(spec.key, order.size); + if (spec.dir !== undefined) { + // First target wins a shared directory; SwiftPM rejects that layout. + if (!spmByDir.has(spec.dir)) spmByDir.set(spec.dir, spec.key); + continue; + } + for (const file of spec.files ?? []) { + const keys = xcodeByFile.get(file); + if (keys === undefined) xcodeByFile.set(file, [spec.key]); + else keys.push(spec.key); + } + const excluded = spec.excluded ?? []; + for (const folder of spec.folders ?? []) { + const entries = xcodeByFolder.get(folder); + const entry = { key: spec.key, excluded }; + if (entries === undefined) xcodeByFolder.set(folder, [entry]); + else entries.push(entry); + } + } + + const leftoversStandalone = + (resolutionConfig as Partial).moduleNamesComplete === true; + const matcher = { spmByDir, xcodeByFile, xcodeByFolder, specByKey, order, leftoversStandalone }; + MATCHERS.set(resolutionConfig, matcher); + return matcher; } -/** Segment-boundary membership used by grouping and declared import resolve. */ -export function fileMatchesSwiftTargetDir(normalizedPath: string, targetDir: string): boolean { - return pathMatchesTargetPrefix(normalizedPath, swiftDeclaredTargetPrefix(targetDir)); +/** Ancestor directories of a file path, deepest first, ending with '' (the root). */ +function ancestorDirs(filePath: string): string[] { + const out: string[] = []; + let i = filePath.lastIndexOf('/'); + while (i > 0) { + out.push(filePath.slice(0, i)); + i = filePath.lastIndexOf('/', i - 1); + } + out.push(''); + return out; +} + +/** `./Sources/App/` → `Sources/App`; `.` → ''. */ +function normalizeDir(dir: string): string { + let norm = dir.replace(/\\/g, '/'); + while (norm.startsWith('./')) norm = norm.slice(2); + norm = norm.replace(/\/+$/, ''); + return norm === '.' ? '' : norm; } diff --git a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts index c6e010029..8f5d1ce66 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts @@ -1,35 +1,54 @@ /** - * Swift same-module (SPM target) implicit visibility for the - * `populateNamespaceSiblings` hook. + * Swift same-module implicit visibility for the `populateNamespaceSiblings` + * hook. * * Swift gives every file in a module access to every other file's * top-level declarations WITHOUT any `import` statement (whole-module - * visibility). This is the Swift analogue of Go's same-package sibling - * visibility — `populateGoPackageSiblings` is the template. + * visibility). Module membership comes from `swiftModuleKeysOf` (SwiftPM + * targets, Xcode targets, else one `__default__` module). * - * Module identity: Swift has no in-source `package X` marker. The SPM - * target is a directory subtree (`Sources//…`). Module membership - * is threaded in via the SPM target map (`ctx.resolutionConfig` → - * `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`. - * The helper preserves the legacy `wireSwiftImplicitImports` bucketing - * contract for ordinary layouts (first target wins and unmatched files use - * `__default__`) but intentionally fixes #2931's repeated-prefix edge case. - * When a target map is present (declared Package.swift or inferred - * `Sources/*` folders), files are grouped by SPM target subtree; - * otherwise ALL Swift files form one module (`__default__`, - * single-Xcode-project assumption). Every `.swift` file in the same target - * sees its siblings' top-level defs. + * Representation: one shared table per module in the language-neutral + * `namespaceFqnBindings` channel, keyed `swift-module:`, and each member + * file's module scope lists that namespace in `accessibleNamespacesByScope`. + * `lookupBindingsAt` consults it at module scope, after the file's own + * bindings. This is how the compiler resolves a name — against one module + * symbol table — and it is O(defs) per module. The earlier form copied every + * file's defs into every other file's scope, O(files² × defs): about 3.8 GB of + * heap for a 1,000-file module (#3355). C# moved to the same channel for the + * same reason (#1871). * - * Bindings are added through the append-only `bindingAugmentations` - * channel (Contract Invariant I8) with `origin: 'namespace'`, exactly - * like the Go implementation — `indexes.bindings` is frozen post- - * finalize and must not be mutated. + * A file's own defs are in its module's table too; its finalized local + * bindings come first and `lookupBindingsAt` dedupes by node id. */ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { isClassLike } from '../../scope-resolution/scope/walkers.js'; -import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; +import { groupSwiftFilesByModule } from './target-grouping.js'; + +/** Namespace-channel key of a Swift module. */ +export function swiftModuleNamespace(moduleKey: string): string { + return `swift-module:${moduleKey}`; +} + +/** + * Make `namespace` visible from each member's module scope. Idempotent, so + * both sibling passes can call it in either order. + */ +export function grantSwiftModuleAccess( + members: readonly ParsedFile[], + namespace: string, + indexes: ScopeResolutionIndexes, +): void { + const accessible = indexes.accessibleNamespacesByScope as Map; + for (const parsed of members) { + const moduleScope = indexes.moduleScopes.byFilePath.get(parsed.filePath); + if (moduleScope === undefined) continue; + const list = accessible.get(moduleScope); + if (list === undefined) accessible.set(moduleScope, [namespace]); + else if (!list.includes(namespace)) list.push(namespace); + } +} export function populateSwiftTargetSiblings( parsedFiles: readonly ParsedFile[], @@ -39,33 +58,34 @@ export function populateSwiftTargetSiblings( readonly resolutionConfig?: unknown; }, ): void { - const targets = coerceSwiftTargets(ctx.resolutionConfig); - const filesByTarget = groupSwiftFilesBySpmTarget( + const augmentations = indexes.bindingAugmentations as Map>; + for (const group of groupSwiftFilesByModule( parsedFiles, (parsed) => parsed.filePath, - targets, - ); - - const augmentations = indexes.bindingAugmentations as Map>; - - for (const [, group] of filesByTarget) { + ctx.resolutionConfig, + ).values()) { populateNestedTypeFragments(group, indexes, augmentations, ctx.fileContents); - if (group.length < 2) continue; // no file siblings to share - const siblings = group.map((parsed) => ({ - filePath: parsed.filePath, - defs: [...parsed.localDefs] as SymbolDefinition[], - })); - for (const target of siblings) { - for (const receiver of siblings) { - if (receiver.filePath === target.filePath) continue; // no self-reference - const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath); - if (receiverModule === undefined) continue; + } - for (const def of target.defs) { - addNamespaceBinding(augmentations, receiverModule, def); - } - } + const namespaceFqn = indexes.namespaceFqnBindings as Map>; + const modules = groupSwiftFilesByModule( + parsedFiles, + (parsed) => parsed.filePath, + ctx.resolutionConfig, + { allMemberships: true }, + ); + for (const [moduleKey, members] of modules) { + if (members.length < 2) continue; // no file siblings to share + const namespace = swiftModuleNamespace(moduleKey); + let table = namespaceFqn.get(namespace); + if (table === undefined) { + table = new Map(); + namespaceFqn.set(namespace, table); } + for (const parsed of members) { + for (const def of parsed.localDefs) addTableBinding(table, def); + } + grantSwiftModuleAccess(members, namespace, indexes); } } @@ -372,6 +392,18 @@ function simpleName(def: SymbolDefinition): string { return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; } +function addTableBinding(table: Map, def: SymbolDefinition): void { + const name = simpleName(def); + if (name === '') return; + let bucket = table.get(name); + if (bucket === undefined) { + bucket = []; + table.set(name, bucket); + } + if (bucket.some((binding) => binding.def.nodeId === def.nodeId)) return; + bucket.push({ def, origin: 'namespace' }); +} + function addNamespaceBinding( augmentations: Map>, scopeId: ScopeId, diff --git a/gitnexus/src/core/ingestion/languages/swift/workspace-config.ts b/gitnexus/src/core/ingestion/languages/swift/workspace-config.ts new file mode 100644 index 000000000..1f6831326 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/workspace-config.ts @@ -0,0 +1,189 @@ +/** + * Swift workspace module discovery (#3355). + * + * `loadSwiftPackageConfig` reads only the root package. A monorepo laid out as + * `Core//Package.swift` has no root manifest and no root `Sources/`, and + * an Xcode app keeps its targets in `.xcodeproj`. Without both, every + * Swift file lands in one `__default__` module that merges unrelated modules. + * + * This loader finds every SwiftPM package and Xcode project in the repo and + * returns one {@link SwiftModuleSpec} per compiler module. SwiftPM targets are + * keyed by their repo-relative directory, so two packages declaring the same + * target name stay two modules. `origin` and `declaredTargets` stay the root's + * for legacy callers; `modules` is the authority for grouping and `import` + * resolution. + * + * Called from `ScopeResolver.loadResolutionConfig`, so only repos with Swift + * pay for the walk — the same split as `loadZigWorkspaceIndex`. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js'; +import { logger } from '../../../logger.js'; +import { + isAbsoluteZigDepPath, + loadSwiftPackageConfig, + normalizeZigDepPath, + type SwiftModuleSpec, + type SwiftPackageConfig, +} from '../../language-config.js'; +import { swiftC99ModuleName } from './target-grouping.js'; +import { parseXcodeProject } from './xcode-project.js'; + +/** Bounds for the workspace walk, same as the Zig one. */ +const SWIFT_SCAN_MAX_DIRS = 20_000; +const SWIFT_SCAN_MAX_DEPTH = 24; + +/** Bundle directories that never hold a manifest or project, often numerous. */ +const SWIFT_SKIPPED_DIR_SUFFIXES = ['.xcassets', '.xcworkspace', '.lproj', '.bundle']; + +interface SwiftWorkspaceScan { + /** Repo-relative directories below the root holding a `Package.swift`. */ + readonly packageDirs: string[]; + /** Repo-relative `.xcodeproj` bundle paths. */ + readonly xcodeProjects: string[]; + readonly truncated: boolean; +} + +export async function loadSwiftWorkspaceConfig( + repoRoot: string, +): Promise { + const root = await loadSwiftPackageConfig(repoRoot); + const scan = await scanSwiftWorkspace(repoRoot); + const modules: SwiftModuleSpec[] = []; + let complete = !scan.truncated; + + const addPackage = (config: SwiftPackageConfig, packageDir: string): void => { + if (config.origin !== 'package.swift') complete = false; + const declared = config.declaredTargets ?? config.targets; + for (const [name, targetDir] of config.targets) { + // An empty result under a nested package is the repo root, whose prefix + // matches every file. + const dir = rebase(packageDir, targetDir); + if (dir === null || (dir === '' && packageDir !== '')) continue; + // Inferred folders are grouping-only unless the package declared nothing + // (`origin: 'directories'`), where the folder name is the best guess. + const importable = config.origin === 'directories' || declared.has(name); + const filter = config.targetFilters?.get(name); + const sources = filter?.sources?.map((rel) => rebase(dir, rel)); + const excluded = filter?.exclude?.map((rel) => rebase(dir, rel)); + modules.push({ + key: dir === '' ? '.' : dir, + name: swiftC99ModuleName(name), + dir, + importable, + ...(sources !== undefined ? { sources: sources.filter(isPath) } : {}), + ...(excluded !== undefined ? { excluded: excluded.filter(isPath) } : {}), + }); + } + }; + + if (root !== null) addPackage(root, ''); + for (const dir of scan.packageDirs) { + const config = await loadSwiftPackageConfig(repoRoot, dir); + if (config !== null) addPackage(config, dir); + } + for (const project of scan.xcodeProjects) { + let source: string; + try { + source = await fs.readFile(path.join(repoRoot, project, 'project.pbxproj'), 'utf-8'); + } catch { + complete = false; + continue; + } + const projectDir = path.posix.dirname(project); + const parsed = parseXcodeProject(source, projectDir === '.' ? '' : projectDir); + if (!parsed.complete) complete = false; + for (const target of parsed.targets) { + modules.push({ + key: `xcode:${project}:${target.name}`, + name: target.moduleName, + files: target.files, + folders: target.folders, + excluded: target.excluded, + importable: true, + }); + } + } + + if (modules.length === 0) return root; + const targets = new Map(); + for (const m of modules) if (m.dir !== undefined) targets.set(m.key, m.dir); + return { + targets, + origin: root?.origin ?? 'directories', + ...(root?.declaredTargets !== undefined ? { declaredTargets: root.declaredTargets } : {}), + modules, + moduleNamesComplete: complete, + }; +} + +/** + * `rel` joined onto the repo-relative `base`, or null when it is absolute or + * escapes the repo — the same rule as Zig path deps. + */ +function rebase(base: string, rel: string): string | null { + if (isAbsoluteZigDepPath(rel)) return null; + return normalizeZigDepPath(base === '' ? rel : `${base}/${rel}`); +} + +function isPath(value: string | null): value is string { + return value !== null; +} + +async function scanSwiftWorkspace(repoRoot: string): Promise { + const packageDirs: string[] = []; + const xcodeProjects: string[] = []; + const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + // Head index, not `queue.shift()` — see `findZigPackageDirs`. + let queueHead = 0; + let truncated = false; + const rel = (dir: string): string => path.relative(repoRoot, dir).split(path.sep).join('/'); + + while (queueHead < queue.length) { + if (queueHead >= SWIFT_SCAN_MAX_DIRS) { + truncated = true; + break; + } + const { dir, depth } = queue[queueHead++]!; + let entries: import('fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + // Sorted so the directories a capped walk reaches do not depend on the + // filesystem's listing order. + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + const name = entry.name; + if (entry.isDirectory()) { + if (name.startsWith('.')) continue; + const childDir = path.join(dir, name); + if (name.endsWith('.xcodeproj')) { + xcodeProjects.push(rel(childDir)); + continue; + } + if (SWIFT_SKIPPED_DIR_SUFFIXES.some((suffix) => name.endsWith(suffix))) continue; + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + // Bound what is queued, not only what is read: a root with a huge + // fan-out would otherwise allocate far past the cap before it trips. + if (depth >= SWIFT_SCAN_MAX_DEPTH || queue.length >= SWIFT_SCAN_MAX_DIRS) { + truncated = true; + continue; + } + queue.push({ dir: childDir, depth: depth + 1 }); + } else if (entry.isFile() && name === 'Package.swift' && dir !== repoRoot) { + packageDirs.push(rel(dir)); + } + } + } + + if (truncated) { + logger.warn( + `[swift] workspace scan of ${repoRoot} truncated (dir cap ${SWIFT_SCAN_MAX_DIRS}, depth cap ${SWIFT_SCAN_MAX_DEPTH}); packages and projects past the cap are not modules`, + ); + } + return { packageDirs, xcodeProjects, truncated }; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/xcode-project.ts b/gitnexus/src/core/ingestion/languages/swift/xcode-project.ts new file mode 100644 index 000000000..0274370e0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/xcode-project.ts @@ -0,0 +1,334 @@ +/** + * Xcode target membership from `project.pbxproj` (#3355). + * + * Each Xcode native target compiles to its own Swift module, so its source + * files see each other's `internal` declarations and nothing else without an + * `import`. Without this, every Swift file outside a SwiftPM target falls into + * one `__default__` module that merges the app, its extensions, and its test + * bundles. + * + * `project.pbxproj` is an old-style (OpenStep) property list. Membership comes + * from two places: + * - classic groups: PBXNativeTarget → PBXSourcesBuildPhase → PBXBuildFile → + * PBXFileReference, with each reference's path resolved through its + * parent PBXGroup chain; + * - Xcode 16 synchronized folders: a target lists + * PBXFileSystemSynchronizedRootGroup folders whose files all belong to it. + * A PBXFileSystemSynchronizedBuildFileExceptionSet on the folder flips the + * default for one target: for a target that lists the folder, its + * `membershipExceptions` are removed; for any other target, they are added + * (how a file in the app's folder is shared with a widget). + * + * Only `` and `SOURCE_ROOT` paths are resolved. SDK, build-product, and + * absolute references point outside the repo and are skipped. Paths that + * escape the repo are dropped. + * + * Module name: the first literal `PRODUCT_MODULE_NAME` in the target's build + * configurations, else its literal `PRODUCT_NAME`, else the target name, the + * last two mangled as the compiler does (`swiftC99ModuleName`). A value built + * from other settings (`$(…)`) is not expanded. + */ + +import { normalizeZigDepPath } from '../../language-config.js'; +import { swiftC99ModuleName } from './target-grouping.js'; + +type PlistValue = string | PlistValue[] | { [key: string]: PlistValue }; +type PlistDict = { [key: string]: PlistValue }; + +export interface XcodeTargetMembership { + readonly name: string; + /** The module name `import` uses. */ + readonly moduleName: string; + /** Repo-relative `.swift` files listed in the target's sources phase. */ + readonly files: string[]; + /** Repo-relative synchronized folders; every file below is a member. */ + readonly folders: string[]; + /** Repo-relative files excluded from this target's synchronized folders. */ + readonly excluded: string[]; +} + +export interface XcodeProjectParse { + readonly targets: XcodeTargetMembership[]; + /** False when the file could not be parsed as a property list. */ + readonly complete: boolean; +} + +/** + * Parse `source` (the text of `/.xcodeproj/project.pbxproj`) + * into target membership. `projectDir` is the repo-relative directory holding + * the `.xcodeproj` bundle ('' for the repo root). + */ +export function parseXcodeProject(source: string, projectDir: string): XcodeProjectParse { + let root: PlistValue; + try { + root = new PlistParser(source).parseDocument(); + } catch { + return { targets: [], complete: false }; + } + if (!isDict(root)) return { targets: [], complete: false }; + const objects = root.objects; + const rootId = root.rootObject; + if (!isDict(objects) || typeof rootId !== 'string') return { targets: [], complete: false }; + const project = objects[rootId]; + if (!isDict(project)) return { targets: [], complete: false }; + + const obj = (id: PlistValue | undefined): PlistDict | undefined => + typeof id === 'string' && isDict(objects[id]) ? (objects[id] as PlistDict) : undefined; + + const base = + typeof project.projectDirPath === 'string' && project.projectDirPath !== '' + ? `${projectDir}/${project.projectDirPath}` + : projectDir; + const sourceRoot = normalizeZigDepPath(base); + + // Resolve every group/file path by walking down from the main group. + const pathById = new Map(); + const visit = (id: string, parentPath: string | null): void => { + const node = obj(id); + if (node === undefined || pathById.has(id)) return; + const own = resolveReferencePath(node, parentPath, sourceRoot); + if (own !== null) pathById.set(id, own); + const children = node.children; + if (Array.isArray(children)) { + for (const child of children) { + if (typeof child === 'string') visit(child, own); + } + } + }; + if (typeof project.mainGroup === 'string') visit(project.mainGroup, sourceRoot); + + const targets: XcodeTargetMembership[] = []; + const targetIds = Array.isArray(project.targets) ? project.targets : []; + const targetNameById = new Map(); + for (const targetId of targetIds) { + const target = obj(targetId); + if (target?.isa === 'PBXNativeTarget' && typeof target.name === 'string') { + targetNameById.set(targetId as string, target.name); + } + } + + // Synchronized folder → the targets that list it (for exception direction). + const folderOwners = new Map>(); + for (const targetId of targetNameById.keys()) { + for (const groupId of asArray(obj(targetId)!.fileSystemSynchronizedGroups)) { + if (typeof groupId !== 'string') continue; + const owners = folderOwners.get(groupId) ?? new Set(); + owners.add(targetId); + folderOwners.set(groupId, owners); + } + } + const exceptionPaths = (groupId: string, exception: PlistDict): string[] => { + const folder = pathById.get(groupId); + if (folder === undefined) return []; + const out: string[] = []; + for (const rel of asArray(exception.membershipExceptions)) { + if (typeof rel !== 'string') continue; + const joined = normalizeZigDepPath(folder === '' ? rel : `${folder}/${rel}`); + if (joined !== null && joined !== '') out.push(joined); + } + return out; + }; + + const byId = new Map(); + for (const [targetId, name] of targetNameById) { + const target = obj(targetId)!; + const files: string[] = []; + for (const phaseId of asArray(target.buildPhases)) { + const phase = obj(phaseId); + if (phase?.isa !== 'PBXSourcesBuildPhase') continue; + for (const buildFileId of asArray(phase.files)) { + const fileRef = obj(buildFileId)?.fileRef; + const filePath = typeof fileRef === 'string' ? pathById.get(fileRef) : undefined; + if (filePath !== undefined && filePath.endsWith('.swift')) files.push(filePath); + } + } + + const folders: string[] = []; + const excluded: string[] = []; + for (const groupId of asArray(target.fileSystemSynchronizedGroups)) { + const folder = typeof groupId === 'string' ? pathById.get(groupId) : undefined; + if (folder === undefined) continue; + folders.push(folder); + } + + const membership = { + name, + moduleName: moduleNameOf(target, name, obj), + files, + folders, + excluded, + }; + byId.set(targetId, membership); + targets.push(membership); + } + + // Exceptions flip membership: remove for a target that lists the folder, + // add for one that does not. + for (const [groupId, owners] of folderOwners) { + for (const exceptionId of asArray(obj(groupId)?.exceptions)) { + const exception = obj(exceptionId); + const targetId = exception?.target; + if (exception === undefined || typeof targetId !== 'string') continue; + const membership = byId.get(targetId); + if (membership === undefined) continue; + const paths = exceptionPaths(groupId, exception); + if (owners.has(targetId)) membership.excluded.push(...paths); + else membership.files.push(...paths.filter((p) => p.endsWith('.swift'))); + } + } + + return { targets, complete: true }; +} + +/** See the file header: literal build settings, else the mangled target name. */ +function moduleNameOf( + target: PlistDict, + targetName: string, + obj: (id: PlistValue | undefined) => PlistDict | undefined, +): string { + const configs = asArray(obj(target.buildConfigurationList)?.buildConfigurations); + const literal = (key: string): string | undefined => { + for (const configId of configs) { + const settings = obj(configId)?.buildSettings; + const value = isDict(settings) ? settings[key] : undefined; + if (typeof value === 'string' && value !== '' && !value.includes('$')) return value; + } + return undefined; + }; + const moduleName = literal('PRODUCT_MODULE_NAME'); + if (moduleName !== undefined) return moduleName; + return swiftC99ModuleName(literal('PRODUCT_NAME') ?? targetName); +} + +/** + * Repo-relative path of a group or file reference, or null when it does not + * live in the repo. A group with no `path` inherits its parent's path. + */ +function resolveReferencePath( + node: PlistDict, + parentPath: string | null, + sourceRoot: string | null, +): string | null { + const own = typeof node.path === 'string' ? node.path : undefined; + const tree = typeof node.sourceTree === 'string' ? node.sourceTree : ''; + let anchor: string | null; + if (tree === '') anchor = parentPath; + else if (tree === 'SOURCE_ROOT') anchor = sourceRoot; + else return null; + if (anchor === null) return null; + if (own === undefined) return anchor; + return normalizeZigDepPath(anchor === '' ? own : `${anchor}/${own}`); +} + +function isDict(v: PlistValue | undefined): v is PlistDict { + return v !== undefined && typeof v === 'object' && !Array.isArray(v); +} + +function asArray(v: PlistValue | undefined): readonly PlistValue[] { + return Array.isArray(v) ? v : []; +} + +/** Minimal OpenStep property-list parser: dicts, arrays, strings. */ +class PlistParser { + private i = 0; + + constructor(private readonly src: string) {} + + parseDocument(): PlistValue { + const value = this.parseValue(); + this.skipTrivia(); + if (this.i < this.src.length) throw new Error('trailing content'); + return value; + } + + private parseValue(): PlistValue { + this.skipTrivia(); + const ch = this.src[this.i]; + if (ch === '{') return this.parseDict(); + if (ch === '(') return this.parseArray(); + if (ch === '"') return this.parseQuoted(); + return this.parseBare(); + } + + private parseDict(): PlistDict { + this.i++; // { + const out: PlistDict = Object.create(null) as PlistDict; + for (;;) { + this.skipTrivia(); + if (this.src[this.i] === '}') { + this.i++; + return out; + } + const key = this.src[this.i] === '"' ? this.parseQuoted() : this.parseBare(); + this.expect('='); + out[key] = this.parseValue(); + this.expect(';'); + } + } + + private parseArray(): PlistValue[] { + this.i++; // ( + const out: PlistValue[] = []; + for (;;) { + this.skipTrivia(); + if (this.src[this.i] === ')') { + this.i++; + return out; + } + out.push(this.parseValue()); + this.skipTrivia(); + if (this.src[this.i] === ',') this.i++; + else if (this.src[this.i] !== ')') throw new Error(`expected , or ) at ${this.i}`); + } + } + + private parseQuoted(): string { + this.i++; // opening quote + let out = ''; + while (this.i < this.src.length) { + const ch = this.src[this.i++]; + if (ch === '"') return out; + if (ch !== '\\') { + out += ch; + continue; + } + const esc = this.src[this.i++]; + if (esc === 'n') out += '\n'; + else if (esc === 't') out += '\t'; + else if (esc === 'U' || esc === 'u') { + const hex = this.src.slice(this.i, this.i + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) throw new Error(`invalid Unicode escape at ${this.i}`); + out += String.fromCharCode(Number.parseInt(hex, 16)); + this.i += 4; + } else out += esc; + } + throw new Error('unterminated string'); + } + + private parseBare(): string { + const start = this.i; + while (this.i < this.src.length && !/[\s{}();=,"]/.test(this.src[this.i]!)) this.i++; + if (this.i === start) throw new Error(`unexpected character at ${this.i}`); + return this.src.slice(start, this.i); + } + + private expect(ch: string): void { + this.skipTrivia(); + if (this.src[this.i] !== ch) throw new Error(`expected ${ch} at ${this.i}`); + this.i++; + } + + private skipTrivia(): void { + for (;;) { + while (this.i < this.src.length && /\s/.test(this.src[this.i]!)) this.i++; + if (this.src.startsWith('//', this.i)) { + const nl = this.src.indexOf('\n', this.i); + this.i = nl === -1 ? this.src.length : nl + 1; + } else if (this.src.startsWith('/*', this.i)) { + const close = this.src.indexOf('*/', this.i + 2); + if (close === -1) throw new Error('unterminated comment'); + this.i = close + 2; + } else return; + } + } +} diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 7df9d530d..970832995 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -9,6 +9,7 @@ import { closeQueryResults } from './query-result-utils.js'; import { chunk } from '../../lib/utils.js'; import { warnIfQueryTextUnbounded } from './query-batch.js'; import { escapeCypherString } from './cypher-escape.js'; +import { MODULE_MEMBERSHIP_REASON } from '../graph/edge-reasons.js'; import { withConnLock } from './conn-lock.js'; import { isWalDriverActive } from './wal-driver-state.js'; import { KnowledgeGraph } from '../graph/types.js'; @@ -2977,6 +2978,30 @@ export const restoreDerivedRels = async (rels: readonly DerivedRelSnapshot[]): P export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME; +/** + * The two importer queries for a `b.filePath ` target set: + * 1. direct importers — any IMPORTS edge into a target file; + * 2. module co-members — files with a `MODULE_MEMBERSHIP_REASON` edge to a + * `Module` node that a target file also has one. Whole-module visibility + * means a declaration added to one member can change how any other member + * resolves, so co-members are importers of each other. The hub form keeps + * this linear in module size (#3355). + */ +const importerCyphers = (targetPredicate: string): string[] => [ + ` + MATCH (a)-[r:${REL_TABLE_NAME}]->(b) + WHERE r.type = 'IMPORTS' AND b.filePath ${targetPredicate} + RETURN DISTINCT a.filePath AS importer + `, + ` + MATCH (a:File)-[r1:${REL_TABLE_NAME}]->(m:Module)<-[r2:${REL_TABLE_NAME}]-(b:File) + WHERE r1.type = 'IMPORTS' AND r1.reason = '${MODULE_MEMBERSHIP_REASON}' + AND r2.type = 'IMPORTS' AND r2.reason = '${MODULE_MEMBERSHIP_REASON}' + AND b.filePath ${targetPredicate} AND a.filePath <> b.filePath + RETURN DISTINCT a.filePath AS importer + `, +]; + /** * Return the distinct repo-relative paths of files that import * `targetFilePath` according to the IMPORTS edges currently in the @@ -2998,31 +3023,29 @@ export const queryImporters = async (targetFilePath: string): Promise throw new Error('LadybugDB not initialized. Call initLbug first.'); } const escaped = escapeCypherString(targetFilePath); - const cypher = ` - MATCH (a)-[r:${REL_TABLE_NAME}]->(b) - WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}' - RETURN DISTINCT a.filePath AS importer - `; + const cyphers = importerCyphers(`= '${escaped}'`); // Runs inside the connection lock: queryImporters is called in the importer-BFS // loop during incremental --pdg writeback while the WAL driver is live, so an // unlocked conn.query here could race a concurrent CHECKPOINT on the singleton. return withConnLock(async () => { - let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; - try { - queryResult = await c.query(cypher); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - const out: string[] = []; - for (const row of rows) { - const v = (row as { importer?: unknown }).importer; - if (typeof v === 'string' && v.length > 0) out.push(v); + const out = new Set(); + for (const cypher of cyphers) { + let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; + try { + queryResult = await c.query(cypher); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const rows = await result.getAll(); + for (const row of rows) { + const v = (row as { importer?: unknown }).importer; + if (typeof v === 'string' && v.length > 0) out.add(v); + } + } catch { + return []; + } finally { + if (queryResult) await closeQueryResults(queryResult); } - return out; - } catch { - return []; - } finally { - if (queryResult) await closeQueryResults(queryResult); } + return [...out]; }); }; @@ -3060,21 +3083,24 @@ export const queryImportersBatch = async ( const importers = new Set(); for (const [chunkIndex, batch] of chunk(targetFilePaths, DELETE_FILES_CHUNK_SIZE).entries()) { const listLiteral = `[${batch.map((p) => `'${escapeCypherString(p)}'`).join(', ')}]`; - const cypher = ` - MATCH (a)-[r:${REL_TABLE_NAME}]->(b) - WHERE r.type = 'IMPORTS' AND b.filePath IN ${listLiteral} - RETURN DISTINCT a.filePath AS importer - `; await withConnLock(async () => { let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; try { - queryResult = await c.query(cypher); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - for (const row of rows) { - const v = (row as { importer?: unknown }).importer; - if (typeof v === 'string' && v.length > 0) importers.add(v); + // Collect into a chunk-local set so a failure on the second query + // drops the whole chunk, as it did when there was one query. + const found: string[] = []; + for (const cypher of importerCyphers(`IN ${listLiteral}`)) { + queryResult = await c.query(cypher); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const rows = await result.getAll(); + for (const row of rows) { + const v = (row as { importer?: unknown }).importer; + if (typeof v === 'string' && v.length > 0) found.push(v); + } + await closeQueryResults(queryResult); + queryResult = undefined; } + for (const v of found) importers.add(v); } catch (err) { // Degrade-don't-fail, mirroring queryImporters — but LOUDLY // (tri-review 4669518496 P2-5): a dropped chunk means every importer diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Package.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Package.swift new file mode 100644 index 000000000..b12b157aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "Net", + targets: [ + .target(name: "Net"), + ] +) diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Client.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Client.swift new file mode 100644 index 000000000..0ca0addf2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Client.swift @@ -0,0 +1,3 @@ +class Client { + func send() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Session.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Session.swift new file mode 100644 index 000000000..0c219852a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Sources/Net/Session.swift @@ -0,0 +1,4 @@ +func openSession() { + let client = Client() + client.send() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Docs/Net/Snippet.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Docs/Net/Snippet.swift new file mode 100644 index 000000000..0ca0addf2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Docs/Net/Snippet.swift @@ -0,0 +1,3 @@ +class Client { + func send() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Package.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Package.swift new file mode 100644 index 000000000..b029bfdbe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "Login", + targets: [ + .target(name: "Login"), + ] +) diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Config.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Config.swift new file mode 100644 index 000000000..6fbb8f6c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Config.swift @@ -0,0 +1,3 @@ +class Config { + func load() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Connect.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Connect.swift new file mode 100644 index 000000000..85198ecb2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/Connect.swift @@ -0,0 +1,6 @@ +import Net + +func connect() { + let client = Client() + client.send() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/LoginFlow.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/LoginFlow.swift new file mode 100644 index 000000000..fbf2a834f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Sources/Login/LoginFlow.swift @@ -0,0 +1,4 @@ +func startLogin() { + let config = Config() + config.load() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Package.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Package.swift new file mode 100644 index 000000000..30b9f5522 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "Other", + targets: [ + .target(name: "Net"), + ] +) diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Config.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Config.swift new file mode 100644 index 000000000..6fbb8f6c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Config.swift @@ -0,0 +1,3 @@ +class Config { + func load() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Use.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Use.swift new file mode 100644 index 000000000..ed6d2da65 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Sources/Net/Use.swift @@ -0,0 +1,4 @@ +func useOther() { + let config = Config() + config.load() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/AppMain.swift b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/AppMain.swift new file mode 100644 index 000000000..d39a7243b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/AppMain.swift @@ -0,0 +1,4 @@ +func startApp() { + let config = Config() + config.load() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/Config.swift b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/Config.swift new file mode 100644 index 000000000..6fbb8f6c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/Config.swift @@ -0,0 +1,3 @@ +class Config { + func load() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Demo.xcodeproj/project.pbxproj b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Demo.xcodeproj/project.pbxproj new file mode 100644 index 000000000..e8b332f56 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Demo.xcodeproj/project.pbxproj @@ -0,0 +1,102 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A1000001 /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000001 /* AppMain.swift */; }; + A1000002 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000002 /* Config.swift */; }; + A1000003 /* WidgetMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000003 /* WidgetMain.swift */; }; + A1000004 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000004 /* Config.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A2000001 /* AppMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = ""; }; + A2000002 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + A2000003 /* WidgetMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetMain.swift; sourceTree = ""; }; + A2000004 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + A3000000 = { + isa = PBXGroup; + children = ( + A3000001 /* App */, + A3000002 /* Widget */, + ); + sourceTree = ""; + }; + A3000001 /* App */ = { + isa = PBXGroup; + children = ( + A2000001 /* AppMain.swift */, + A2000002 /* Config.swift */, + ); + path = App; + sourceTree = ""; + }; + A3000002 /* Widget */ = { + isa = PBXGroup; + children = ( + A2000003 /* WidgetMain.swift */, + A2000004 /* Config.swift */, + ); + path = Widget; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A4000001 /* App */ = { + isa = PBXNativeTarget; + buildPhases = ( + A5000001 /* Sources */, + ); + name = App; + productType = "com.apple.product-type.application"; + }; + A4000002 /* Widget */ = { + isa = PBXNativeTarget; + buildPhases = ( + A5000002 /* Sources */, + ); + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A6000000 /* Project object */ = { + isa = PBXProject; + mainGroup = A3000000; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A4000001 /* App */, + A4000002 /* Widget */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + A5000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + A1000001 /* AppMain.swift in Sources */, + A1000002 /* Config.swift in Sources */, + ); + }; + A5000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + A1000003 /* WidgetMain.swift in Sources */, + A1000004 /* Config.swift in Sources */, + ); + }; +/* End PBXSourcesBuildPhase section */ + }; + rootObject = A6000000 /* Project object */; +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/Config.swift b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/Config.swift new file mode 100644 index 000000000..6fbb8f6c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/Config.swift @@ -0,0 +1,3 @@ +class Config { + func load() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/WidgetMain.swift b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/WidgetMain.swift new file mode 100644 index 000000000..808702392 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/WidgetMain.swift @@ -0,0 +1,4 @@ +func startWidget() { + let config = Config() + config.load() +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index d4c313237..b6c40d1b1 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -195,6 +195,50 @@ "captureGroups": 13, "digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab" }, + "swift-nested-packages/Core/Net/Package.swift": { + "captureGroups": 6, + "digest": "f953980204f1643bc4078cefb3c68b1964ca60c3dec5b34d985c9afc228ac0c8" + }, + "swift-nested-packages/Core/Net/Sources/Net/Client.swift": { + "captureGroups": 6, + "digest": "4b8d4a7f228aea7bef1d7bb35fec7ff1e15b7767d4342dfb406691dc37494101" + }, + "swift-nested-packages/Core/Net/Sources/Net/Session.swift": { + "captureGroups": 8, + "digest": "fd0c092b3087ffaab98d467079dfbb582f8607b7c52331513a1bfac84cc13a4a" + }, + "swift-nested-packages/Docs/Net/Snippet.swift": { + "captureGroups": 6, + "digest": "4b8d4a7f228aea7bef1d7bb35fec7ff1e15b7767d4342dfb406691dc37494101" + }, + "swift-nested-packages/Features/Login/Package.swift": { + "captureGroups": 6, + "digest": "5c61c90b6eac35feae13a8acf932b579642319a19daf1167a6e7416f9b4696c4" + }, + "swift-nested-packages/Features/Login/Sources/Login/Config.swift": { + "captureGroups": 6, + "digest": "84dfcff34e31d09c0eb692a05d9cf2aad4507b1357b24c882fe4d67ce914a1ae" + }, + "swift-nested-packages/Features/Login/Sources/Login/Connect.swift": { + "captureGroups": 9, + "digest": "965b487addf7ba0e65a6fec3abaad465fe359c2b8b353bf95d316fb5697df364" + }, + "swift-nested-packages/Features/Login/Sources/Login/LoginFlow.swift": { + "captureGroups": 8, + "digest": "65133f32b8f3a7fb439f230b402866c0d5946a15f552c000c68c6b546bf9d985" + }, + "swift-nested-packages/Features/Other/Package.swift": { + "captureGroups": 6, + "digest": "8e3383d91e40d61529efc9f05e0820014a742180b695673f93282f517559038c" + }, + "swift-nested-packages/Features/Other/Sources/Net/Config.swift": { + "captureGroups": 6, + "digest": "84dfcff34e31d09c0eb692a05d9cf2aad4507b1357b24c882fe4d67ce914a1ae" + }, + "swift-nested-packages/Features/Other/Sources/Net/Use.swift": { + "captureGroups": 8, + "digest": "bbb3d04da1054e094206650be5d36a5ceb0958b8d6e8660dd00e589c5df4ea58" + }, "swift-overload-dispatch/App.swift": { "captureGroups": 8, "digest": "da644f9163f61ef3ec0a000d31dd93b5f0dd11b33def9d96ffe425ff4288b922" @@ -267,6 +311,22 @@ "captureGroups": 11, "digest": "133a8a1186742b19b7408703262fed82ebdf2aa48b9728ae78aa3b26b5b06428" }, + "swift-xcode-targets/App/AppMain.swift": { + "captureGroups": 8, + "digest": "31be46b8eefb24ae279b136de3a3d90303a06a3adcb6dc748b515cf176fade38" + }, + "swift-xcode-targets/App/Config.swift": { + "captureGroups": 6, + "digest": "84dfcff34e31d09c0eb692a05d9cf2aad4507b1357b24c882fe4d67ce914a1ae" + }, + "swift-xcode-targets/Widget/Config.swift": { + "captureGroups": 6, + "digest": "84dfcff34e31d09c0eb692a05d9cf2aad4507b1357b24c882fe4d67ce914a1ae" + }, + "swift-xcode-targets/Widget/WidgetMain.swift": { + "captureGroups": 8, + "digest": "1dd3dd883196db9b313447cffaf0520640ec1de7fef68034b4058ea51f024bcd" + }, "synthetic:dao-20": { "captureGroups": 361, "digest": "56dde1e182164a4ad86de0254e7b683596d9b0340ec941fc09cdff598f16c6dc" diff --git a/gitnexus/test/integration/lbug-query-importers-batch.test.ts b/gitnexus/test/integration/lbug-query-importers-batch.test.ts index 26a2ae557..d32f9d546 100644 --- a/gitnexus/test/integration/lbug-query-importers-batch.test.ts +++ b/gitnexus/test/integration/lbug-query-importers-batch.test.ts @@ -108,6 +108,67 @@ withTestLbugDB('query-importers-batch', (handle) => { }); }); +withTestLbugDB('query-importers-batch-module-members', (handle) => { + describe('queryImportersBatch — module co-members (#3355)', () => { + it('returns files sharing a module-membership hub, and ignores other File->Module edges', async () => { + const { loadGraphToLbug, queryImportersBatch } = + await import('../../src/core/lbug/lbug-adapter.js'); + const { MODULE_MEMBERSHIP_REASON } = await import('../../src/core/graph/edge-reasons.js'); + + const file = (fp: string): TestNodeInput => ({ + id: `File:${fp}`, + label: 'File', + name: path.basename(fp), + filePath: fp, + }); + const nodes: TestNodeInput[] = [ + file('Net/A.swift'), + file('Net/B.swift'), + file('Net/C.swift'), + file('Login/D.swift'), + file('jobs/one.jcl'), + file('jobs/two.jcl'), + { id: 'Module:swift:Net', label: 'Module', name: 'Net', filePath: 'Net' }, + { id: 'Module:swift:Login', label: 'Module', name: 'Login', filePath: 'Login' }, + { id: 'Module:proc', label: 'Module', name: 'PROC', filePath: 'jobs/proc.jcl' }, + ]; + const member = (fp: string, moduleId: string): TestRelInput => ({ + sourceId: `File:${fp}`, + targetId: moduleId, + type: 'IMPORTS', + reason: MODULE_MEMBERSHIP_REASON, + }); + const rels: TestRelInput[] = [ + member('Net/A.swift', 'Module:swift:Net'), + member('Net/B.swift', 'Module:swift:Net'), + member('Net/C.swift', 'Module:swift:Net'), + member('Login/D.swift', 'Module:swift:Login'), + // Two JCL jobs including the same PROC are not co-dependent. + { + sourceId: 'File:jobs/one.jcl', + targetId: 'Module:proc', + type: 'IMPORTS', + reason: 'jcl-include', + }, + { + sourceId: 'File:jobs/two.jcl', + targetId: 'Module:proc', + type: 'IMPORTS', + reason: 'jcl-include', + }, + ]; + await loadGraphToLbug(buildTestGraph(nodes, rels), '/tmp/repo', path.dirname(handle.dbPath)); + + await expect(queryImportersBatch(['Net/B.swift'])).resolves.toEqual([ + 'Net/A.swift', + 'Net/C.swift', + ]); + await expect(queryImportersBatch(['Login/D.swift'])).resolves.toEqual([]); + await expect(queryImportersBatch(['jobs/one.jcl'])).resolves.toEqual([]); + }, 120_000); + }); +}); + // Dedicated trailing block: the DROP below poisons this DB for any further // CodeRelation query, so no other test may share it. withTestLbugDB('query-importers-batch-failure', () => { diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 9805abf28..5d3e0bd85 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -19,9 +19,22 @@ import { } from './helpers.js'; import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages } from '../../../src/config/supported-languages.js'; +import { MODULE_MEMBERSHIP_REASON } from '../../../src/core/graph/edge-reasons.js'; const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); +/** + * Module nodes a file is a member of. Same-module visibility is one + * File -> Module membership edge per file (#3355), so two files see each + * other exactly when they share a hub. + */ +function moduleHubsOf(result: PipelineResult, filePath: string): string[] { + return getRelationships(result, 'IMPORTS') + .filter((c) => c.rel.reason === MODULE_MEMBERSHIP_REASON && c.sourceFilePath === filePath) + .map((c) => c.rel.targetId) + .sort(); +} + describe.skipIf(!swiftAvailable)('Swift constructor-inferred type resolution', () => { let result: PipelineResult; @@ -262,14 +275,9 @@ describe.skipIf(!swiftAvailable)('Swift implicit imports (cross-file visibility) expect(memberCall).toBeDefined(); }); - it('creates IMPORTS edges between files in the same module', () => { - const imports = getRelationships(result, 'IMPORTS'); - const crossFileImport = imports.find( - (c) => - (c.sourceFilePath === 'App.swift' && c.targetFilePath === 'Models.swift') || - (c.sourceFilePath === 'Models.swift' && c.targetFilePath === 'App.swift'), - ); - expect(crossFileImport).toBeDefined(); + it('makes files in the same module members of one Module hub', () => { + expect(moduleHubsOf(result, 'App.swift')).toHaveLength(1); + expect(moduleHubsOf(result, 'App.swift')).toEqual(moduleHubsOf(result, 'Models.swift')); }); }); @@ -985,20 +993,11 @@ describe.skipIf(!swiftAvailable)('Swift SPM multi-directory target grouping', () expect(memberCall!.targetFilePath).toBe('Sources/Alpha/Core/User.swift'); }); - it('emits cross-directory IMPORTS edges within the Alpha target (Entry <-> Core)', () => { - const imports = getRelationships(result, 'IMPORTS'); - const entryToCore = imports.find( - (c) => - c.sourceFilePath === 'Sources/Alpha/Entry/App.swift' && - c.targetFilePath === 'Sources/Alpha/Core/User.swift', - ); - const coreToEntry = imports.find( - (c) => - c.sourceFilePath === 'Sources/Alpha/Core/User.swift' && - c.targetFilePath === 'Sources/Alpha/Entry/App.swift', - ); - expect(entryToCore).toBeDefined(); - expect(coreToEntry).toBeDefined(); + it('puts both directories of the Alpha target in one module (Entry <-> Core)', () => { + const entry = moduleHubsOf(result, 'Sources/Alpha/Entry/App.swift'); + expect(entry).toHaveLength(1); + expect(entry).toEqual(moduleHubsOf(result, 'Sources/Alpha/Core/User.swift')); + expect(entry).not.toEqual(moduleHubsOf(result, 'Sources/Beta/Core/User.swift')); }); it('does NOT emit IMPORTS across distinct targets (no Alpha <-> Beta)', () => { @@ -1054,18 +1053,119 @@ describe.skipIf(!swiftAvailable)( expect(memberCall!.targetFilePath).toBe('Models/User.swift'); }); - it('emits cross-folder IMPORTS edges between Models and Services', () => { - const imports = getRelationships(result, 'IMPORTS'); - const crossFolder = imports.find( - (c) => - (c.sourceFilePath === 'Services/App.swift' && c.targetFilePath === 'Models/User.swift') || - (c.sourceFilePath === 'Models/User.swift' && c.targetFilePath === 'Services/App.swift'), + it('puts Models and Services in one __default__ module', () => { + expect(moduleHubsOf(result, 'Services/App.swift')).toHaveLength(1); + expect(moduleHubsOf(result, 'Services/App.swift')).toEqual( + moduleHubsOf(result, 'Models/User.swift'), ); - expect(crossFolder).toBeDefined(); }); }, ); +// --------------------------------------------------------------------------- +// #3355 — Nested SwiftPM packages with no root manifest. Each package's +// targets are their own module: files see same-target siblings only, and two +// packages declaring a target named `Net` stay two modules. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift nested packages (no root Package.swift)', () => { + let result: PipelineResult; + const login = 'Features/Login/Sources/Login'; + const otherNet = 'Features/Other/Sources/Net'; + const coreNet = 'Core/Net/Sources/Net'; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-nested-packages'), () => {}); + }, 60000); + + it('puts files of the same target in one module', () => { + expect(moduleHubsOf(result, `${coreNet}/Session.swift`)).toHaveLength(1); + expect(moduleHubsOf(result, `${coreNet}/Session.swift`)).toEqual( + moduleHubsOf(result, `${coreNet}/Client.swift`), + ); + expect(moduleHubsOf(result, `${login}/LoginFlow.swift`)).toHaveLength(1); + expect(moduleHubsOf(result, `${login}/LoginFlow.swift`)).toEqual( + moduleHubsOf(result, `${login}/Config.swift`), + ); + }); + + it('keeps every package in its own module, including same-named targets', () => { + const hubs = [ + `${coreNet}/Client.swift`, + `${otherNet}/Config.swift`, + `${login}/Config.swift`, + ].map((file) => moduleHubsOf(result, file)); + expect(hubs.every((h) => h.length === 1)).toBe(true); + expect(new Set(hubs.map((h) => h[0])).size).toBe(3); + }); + + it('keeps package manifests and files outside every target out of any module', () => { + for (const file of [ + 'Core/Net/Package.swift', + 'Features/Login/Package.swift', + 'Docs/Net/Snippet.swift', + ]) { + expect(moduleHubsOf(result, file)).toEqual([]); + } + }); + + it('resolves `import Net` by module name, not by a folder that happens to be named Net', () => { + const imported = getRelationships(result, 'IMPORTS') + .filter((c) => c.sourceFilePath === `${login}/Connect.swift` && c.targetLabel === 'File') + .map((c) => c.targetFilePath) + .sort(); + expect(imported).toEqual([ + `${coreNet}/Client.swift`, + `${coreNet}/Session.swift`, + `${otherNet}/Config.swift`, + `${otherNet}/Use.swift`, + ]); + }); + + it("resolves Config() to the caller's own package", () => { + const ctorCalls = getRelationships(result, 'CALLS').filter( + (c) => c.target === 'Config' && c.targetLabel === 'Class', + ); + expect(ctorCalls.map((c) => `${c.sourceFilePath}->${c.targetFilePath}`).sort()).toEqual([ + `${login}/LoginFlow.swift->${login}/Config.swift`, + `${otherNet}/Use.swift->${otherNet}/Config.swift`, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// #3355 — Xcode native targets from project.pbxproj. The app and its widget +// extension are separate modules that each declare a `Config` class. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift Xcode targets (project.pbxproj)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-xcode-targets'), () => {}); + }, 60000); + + it('makes each Xcode target its own module', () => { + const app = moduleHubsOf(result, 'App/AppMain.swift'); + const widget = moduleHubsOf(result, 'Widget/WidgetMain.swift'); + expect(app).toHaveLength(1); + expect(widget).toHaveLength(1); + expect(app).toEqual(moduleHubsOf(result, 'App/Config.swift')); + expect(widget).toEqual(moduleHubsOf(result, 'Widget/Config.swift')); + expect(app).not.toEqual(widget); + }); + + it("resolves Config() to the caller's own target", () => { + const ctorCalls = getRelationships(result, 'CALLS').filter( + (c) => c.target === 'Config' && c.targetLabel === 'Class', + ); + expect(ctorCalls.map((c) => `${c.sourceFilePath}->${c.targetFilePath}`).sort()).toEqual([ + 'App/AppMain.swift->App/Config.swift', + 'Widget/WidgetMain.swift->Widget/Config.swift', + ]); + }); +}); + // --------------------------------------------------------------------------- // U4 — BUG1: member-write read/write classification (issue #1948). A Swift // assignment LHS `obj.field = x` is wrapped in `directly_assignable_expression` diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 64d095d9e..228fcead8 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -586,6 +586,65 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { }); }); +describe('Swift: isGlobalNameFallbackPlausible with workspace modules (#3355)', () => { + const resolutionConfig = { + targets: new Map(), + modules: [ + { key: 'Pkg/Lib', name: 'Lib', dir: 'Pkg/Lib', importable: true }, + { key: 'Pkg/Plugins/Gen', name: 'Gen', dir: 'Pkg/Plugins/Gen', importable: false }, + { key: 'Pkg/Tests/LibTests', name: 'LibTests', dir: 'Pkg/Tests/LibTests', importable: true }, + { + key: 'xcode:App.xcodeproj:App', + name: 'App', + files: ['App/Main.swift', 'App/Shared.swift'], + importable: true, + }, + { + key: 'xcode:App.xcodeproj:Widget', + name: 'Widget', + files: ['App/Widget.swift', 'App/Shared.swift'], + importable: true, + }, + ], + }; + const check = (caller: ParsedFile, candidatePath: string): boolean => + swiftIsGlobalNameFallbackPlausible({ + callerParsed: caller, + candidate: mkCandidate(candidatePath, 'helper'), + resolutionConfig, + }); + + it('gives a custom-path target a module identity', () => { + expect(check(mkCaller('App/Main.swift'), 'Pkg/Lib/Helper.swift')).toBe(false); + expect(check(mkCaller('App/Main.swift', [namedImport('Lib')]), 'Pkg/Lib/Helper.swift')).toBe( + true, + ); + }); + + it('requires a test target to import the module it tests (@testable import)', () => { + expect(check(mkCaller('Pkg/Tests/LibTests/T.swift'), 'Pkg/Lib/Helper.swift')).toBe(false); + expect( + check(mkCaller('Pkg/Tests/LibTests/T.swift', [namedImport('Lib')]), 'Pkg/Lib/Helper.swift'), + ).toBe(true); + }); + + it('separates Xcode targets and shares a file compiled into both', () => { + expect(check(mkCaller('App/Widget.swift'), 'App/Main.swift')).toBe(false); + expect(check(mkCaller('App/Widget.swift'), 'App/Shared.swift')).toBe(true); + expect(check(mkCaller('App/Main.swift'), 'App/Shared.swift')).toBe(true); + }); + + it('does not treat an import of a plugin name as reaching the plugin', () => { + expect( + check(mkCaller('App/Main.swift', [namedImport('Gen')]), 'Pkg/Plugins/Gen/main.swift'), + ).toBe(false); + }); + + it('allows when either side is outside every known module', () => { + expect(check(mkCaller('Loose/Script.swift'), 'Pkg/Lib/Helper.swift')).toBe(true); + }); +}); + describe('Swift: isGlobalNameFallbackPlausible', () => { it.each(['sources', 'tests', 'Tests'])( 'does not invent target modules under unconfigured %s folders', diff --git a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts index f8e139968..c66686231 100644 --- a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -1,47 +1,50 @@ import { describe, expect, it } from 'vitest'; -import { groupSwiftFilesBySpmTarget } from '../../../src/core/ingestion/languages/swift/target-grouping.js'; +import { groupSwiftFilesByModule } from '../../../src/core/ingestion/languages/swift/target-grouping.js'; -describe('groupSwiftFilesBySpmTarget', () => { - it('matches a later segment-aligned target prefix after an earlier partial occurrence', () => { - const targets = new Map([['Core', 'Modules/Core']]); +// #2931 made grouping accept a target prefix found anywhere in the path, as a +// stand-in for nested-package discovery. The workspace loader now rebases every +// target to the repo root (#3355), so matching is anchored there: a repeated +// layout further down the path is a different package, not this target. +describe('groupSwiftFilesByModule — anchored target matching', () => { + const config = { targets: new Map([['Core', 'Modules/Core']]) }; + + it('does not match a target prefix that appears only further down the path', () => { const items = ['vendor/SubModules/Core/shim/Modules/Core/Thing.swift']; - const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + const groups = groupSwiftFilesByModule(items, (item) => item, config); - expect(groups.get('Core')).toEqual(items); - expect(groups.get('__default__')).toBeUndefined(); + expect(groups.get('Core')).toBeUndefined(); + expect(groups.get('__default__')).toEqual(items); }); - it('keeps an earlier segment-aligned match when a later occurrence is partial', () => { - const targets = new Map([['Core', 'Modules/Core']]); + it('matches a target dir at the repo root even when the name repeats below it', () => { const items = ['Modules/Core/Thing/SubModules/Core/Thing.swift']; - const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + const groups = groupSwiftFilesByModule(items, (item) => item, config); expect(groups.get('Core')).toEqual(items); - expect(groups.get('__default__')).toBeUndefined(); }); it('does not treat a partial path segment as a target match', () => { - const targets = new Map([['Core', 'Modules/Core']]); const item = 'vendor/SubModules/Core/Thing.swift'; - const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + const groups = groupSwiftFilesByModule([item], (value) => value, config); - expect(groups.get('Core')).toBeUndefined(); expect(groups.get('__default__')).toEqual([item]); }); - it('keeps first-target-wins behavior when target paths overlap', () => { - const targets = new Map([ - ['Outer', 'Sources'], - ['Inner', 'Sources/Feature'], - ]); + it('prefers the deepest target when target paths overlap', () => { + const overlapping = { + targets: new Map([ + ['Outer', 'Sources'], + ['Inner', 'Sources/Feature'], + ]), + }; const item = 'Sources/Feature/Thing.swift'; - const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + const groups = groupSwiftFilesByModule([item], (value) => value, overlapping); - expect(groups.get('Outer')).toEqual([item]); - expect(groups.get('Inner')).toBeUndefined(); + expect(groups.get('Inner')).toEqual([item]); + expect(groups.get('Outer')).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts index 50cb49099..8687143cc 100644 --- a/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts @@ -7,7 +7,11 @@ import { describe, expect, it } from 'vitest'; import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared'; import { createKnowledgeGraph } from '../../../../src/core/graph/graph.js'; import { generateId } from '../../../../src/lib/utils.js'; -import { emitSwiftImplicitImportEdges } from '../../../../src/core/ingestion/languages/swift/implicit-imports.js'; +import { + emitSwiftImplicitImportEdges, + swiftModuleNodeId, +} from '../../../../src/core/ingestion/languages/swift/implicit-imports.js'; +import { MODULE_MEMBERSHIP_REASON } from '../../../../src/core/graph/edge-reasons.js'; import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; const DECLARED = { @@ -55,11 +59,9 @@ describe('emitSwiftImplicitImportEdges', () => { emitSwiftImplicitImportEdges(graph, parsed, new Map(), DECLARED); const pairs = importPair(graph.relationships); + const hubA = swiftModuleNodeId('A'); expect(pairs).toEqual( - [ - `${generateId('File', a)}->${generateId('File', other)}`, - `${generateId('File', other)}->${generateId('File', a)}`, - ].sort(), + [`${generateId('File', a)}->${hubA}`, `${generateId('File', other)}->${hubA}`].sort(), ); expect(pairs.some((pair) => pair.includes(generateId('File', b)))).toBe(false); @@ -72,3 +74,93 @@ describe('emitSwiftImplicitImportEdges', () => { expect(fromApp).toEqual(expect.arrayContaining([a, other, b])); }); }); + +describe('emitSwiftImplicitImportEdges — one Module hub per module (#3355)', () => { + const files = (dir: string, n: number): ParsedFile[] => + Array.from({ length: n }, (_, i) => stubFile(`${dir}/F${i}.swift`)); + + it('emits one Module node and one membership edge per member file', () => { + const graph = createKnowledgeGraph(); + emitSwiftImplicitImportEdges(graph, files('Sources/A', 4), new Map(), DECLARED); + + const edges = graph.relationships.filter((rel) => rel.type === 'IMPORTS'); + expect(edges).toHaveLength(4); + expect(edges.every((rel) => rel.targetId === swiftModuleNodeId('A'))).toBe(true); + expect(edges.every((rel) => rel.reason === MODULE_MEMBERSHIP_REASON)).toBe(true); + expect(graph.getNode(swiftModuleNodeId('A'))).toMatchObject({ + label: 'Module', + properties: { name: 'A', filePath: 'Sources/A' }, + }); + }); + + it('stays linear in module size where the pairwise form would exceed the Map limit', () => { + const graph = createKnowledgeGraph(); + // 5,000 files: pairwise would be ~25M edges, over V8's 2^24 Map entries. + emitSwiftImplicitImportEdges(graph, files('App', 5000), new Map(), null); + + expect(graph.relationships.filter((rel) => rel.type === 'IMPORTS')).toHaveLength(5000); + expect(graph.getNode(swiftModuleNodeId('__default__'))).toMatchObject({ + properties: { name: '__default__', filePath: '.' }, + }); + }); + + it('skips single-file modules and keeps modules apart', () => { + const graph = createKnowledgeGraph(); + emitSwiftImplicitImportEdges( + graph, + [...files('Sources/A', 2), ...files('Sources/B', 1)], + new Map(), + DECLARED, + ); + + expect(importPair(graph.relationships)).toEqual( + [ + `${generateId('File', 'Sources/A/F0.swift')}->${swiftModuleNodeId('A')}`, + `${generateId('File', 'Sources/A/F1.swift')}->${swiftModuleNodeId('A')}`, + ].sort(), + ); + expect(graph.getNode(swiftModuleNodeId('B'))).toBeUndefined(); + }); + + it('links a file compiled into two Xcode targets to both modules', () => { + const shared = 'App/Shared.swift'; + const config = { + targets: new Map(), + modules: [ + { + key: 'xcode:App.xcodeproj:App', + name: 'App', + files: [shared, 'App/Main.swift'], + importable: true, + }, + { + key: 'xcode:App.xcodeproj:Widget', + name: 'Widget', + files: [shared, 'App/Widget.swift'], + importable: true, + }, + ], + }; + const graph = createKnowledgeGraph(); + emitSwiftImplicitImportEdges( + graph, + [stubFile(shared), stubFile('App/Main.swift'), stubFile('App/Widget.swift')], + new Map(), + config, + ); + + const fromShared = graph.relationships + .filter((rel) => rel.sourceId === generateId('File', shared)) + .map((rel) => rel.targetId) + .sort(); + expect(fromShared).toEqual( + [ + swiftModuleNodeId('xcode:App.xcodeproj:App'), + swiftModuleNodeId('xcode:App.xcodeproj:Widget'), + ].sort(), + ); + expect(graph.getNode(swiftModuleNodeId('xcode:App.xcodeproj:App'))).toMatchObject({ + properties: { filePath: 'App.xcodeproj' }, + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts index 4bbe2f732..8d5d2ca37 100644 --- a/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts @@ -127,3 +127,73 @@ describe('resolveSwiftImportTarget — declared Package.swift (R3, R4)', () => { ).toEqual(['Sources/Models/Other.swift']); }); }); + +describe('resolveSwiftImportTarget — workspace modules (#3355)', () => { + const files = [ + 'Core/Net/Sources/Net/Client.swift', + 'Core/Net/Tests/NetTests/ClientTests.swift', + 'Features/Other/Sources/Net/Config.swift', + 'Features/Login/Sources/Login/Flow.swift', + 'Features/Login/Plugins/Gen/main.swift', + 'Docs/Net/Snippet.swift', + 'App/Main.swift', + ]; + const modules = [ + { key: 'Core/Net/Sources/Net', name: 'Net', dir: 'Core/Net/Sources/Net', importable: true }, + { + key: 'Core/Net/Tests/NetTests', + name: 'NetTests', + dir: 'Core/Net/Tests/NetTests', + importable: true, + }, + { + key: 'Features/Other/Sources/Net', + name: 'Net', + dir: 'Features/Other/Sources/Net', + importable: true, + }, + { + key: 'Features/Login/Sources/Login', + name: 'Login', + dir: 'Features/Login/Sources/Login', + importable: true, + }, + { + key: 'Features/Login/Plugins/Gen', + name: 'Gen', + dir: 'Features/Login/Plugins/Gen', + importable: false, + }, + { key: 'xcode:App.xcodeproj:App', name: 'App', files: ['App/Main.swift'], importable: true }, + ]; + const config = (complete: boolean) => ({ + targets: new Map(), + modules, + moduleNamesComplete: complete, + }); + const from = 'Features/Login/Sources/Login/Flow.swift'; + + it('resolves a name to every module carrying it, not to folders named alike', () => { + expect(resolve('Net', files, from, config(true))).toEqual([ + 'Core/Net/Sources/Net/Client.swift', + 'Features/Other/Sources/Net/Config.swift', + ]); + }); + + it('resolves an Xcode target by name', () => { + expect(resolve('App', files, from, config(true))).toEqual(['App/Main.swift']); + }); + + it('never resolves a plugin, which is not importable', () => { + expect(resolve('Gen', files, from, config(true))).toBeNull(); + expect(resolve('Gen', files, from, config(false))).toBeNull(); + }); + + it('treats an unknown name as external when every manifest was read', () => { + expect(resolve('Docs', files, from, config(true))).toBeNull(); + }); + + it('falls back to the folder index for an unknown name when some manifest was not read', () => { + expect(resolve('Docs', files, from, config(false))).toEqual(['Docs/Net/Snippet.swift']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts index 03ef37247..9513c24b9 100644 --- a/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts @@ -13,9 +13,16 @@ import path from 'node:path'; import { loadSwiftPackageConfig, parseSwiftPackageManifest, + type SwiftPackageConfig, swiftDeclaredTargetPrefix, } from '../../../../src/core/ingestion/language-config.js'; -import { coerceDeclaredSwiftTargets } from '../../../../src/core/ingestion/languages/swift/target-grouping.js'; +import { + coerceDeclaredSwiftTargets, + groupSwiftFilesByModule, +} from '../../../../src/core/ingestion/languages/swift/target-grouping.js'; +import { loadSwiftWorkspaceConfig } from '../../../../src/core/ingestion/languages/swift/workspace-config.js'; +import { parseXcodeProject } from '../../../../src/core/ingestion/languages/swift/xcode-project.js'; +import { _captureLogger } from '../../../../src/core/logger.js'; const roots: string[] = []; @@ -73,16 +80,22 @@ describe('parseSwiftPackageManifest', () => { expect(parsed.targets.get('AppTests')).toBe('Tests/AppTests'); }); - it('skips binary / plugin / systemLibrary targets', () => { + it('skips binary / systemLibrary targets, which have no Swift sources', () => { const parsed = parseSwiftPackageManifest(` .binaryTarget(name: "Lib", path: "Lib.xcframework") - .plugin(name: "Gen") .systemLibrary(name: "CFoo") `); expect(parsed.complete).toBe(true); expect(parsed.targets.size).toBe(0); }); + it('keeps plugin targets as modules under Plugins/, marked non-importable', () => { + const parsed = parseSwiftPackageManifest(`.plugin(name: "Gen", capability: .buildTool())`); + expect(parsed.targets.get('Gen')).toBe('Plugins/Gen'); + expect([...parsed.plugins]).toEqual(['Gen']); + expect(parsed.implicitDirs.get('Gen')).toBe('plugin'); + }); + it('treats #if as a completeness hazard', () => { const parsed = parseSwiftPackageManifest(` #if os(macOS) @@ -450,3 +463,342 @@ let package = Package( expect(cfg!.targets.get('T')).toBe('Sources/T'); }); }); + +const pkg = (...targets: string[]): string => + `let package = Package(name: "P", targets: [${targets.join(', ')}])`; + +const moduleKeys = (cfg: SwiftPackageConfig | null): string[] => + (cfg?.modules ?? []).map((m) => m.key).sort(); + +describe('loadSwiftPackageConfig — SwiftPM directory rules', () => { + it('prefers the first predefined parent when inferring folders with no manifest', async () => { + const root = repo({ 'Sources/Foo/a.swift': '', 'srcs/Foo/b.swift': '' }); + expect((await loadSwiftPackageConfig(root))?.targets.get('Foo')).toBe('Sources/Foo'); + }); + + it('picks one predefined parent per package: the first of Sources, Source, src, srcs', async () => { + const root = repo({ + 'Package.swift': pkg( + '.target(name: "A")', + '.target(name: "B")', + '.testTarget(name: "BTests")', + ), + 'Source/A/a.swift': '', + 'srcs/B/b.swift': '', + 'Tests/BTests/t.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + + expect(cfg?.targets.get('A')).toBe('Source/A'); + // SwiftPM does not fall through per target: B belongs under Source/ too. + expect(cfg?.targets.get('B')).toBe('Source/B'); + expect(cfg?.targets.get('BTests')).toBe('Tests/BTests'); + }); + + it('looks for test targets under the source parent when there is no Tests/', async () => { + const root = repo({ + 'Package.swift': pkg('.testTarget(name: "LibTests")'), + 'Sources/LibTests/t.swift': '', + }); + + expect((await loadSwiftPackageConfig(root))?.targets.get('LibTests')).toBe('Sources/LibTests'); + }); + + it('reads sources: and exclude: lists, and treats a computed list as unreadable', () => { + const parsed = parseSwiftPackageManifest( + pkg( + '.target(name: "Lib", exclude: ["Legacy", "README.md"], sources: ["Core", "Main.swift"])', + ), + ); + expect(parsed.filters.get('Lib')).toEqual({ + sources: ['Core', 'Main.swift'], + exclude: ['Legacy', 'README.md'], + }); + expect(parseSwiftPackageManifest(pkg('.target(name: "Lib", exclude: excluded)')).complete).toBe( + false, + ); + }); + + it('reads the highest Package@swift-X.Y.swift over Package.swift', async () => { + const root = repo({ + 'Package.swift': pkg('.target(name: "Old")'), + 'Package@swift-5.9.swift': pkg('.target(name: "New59")'), + 'Package@swift-5.10.swift': pkg('.target(name: "New510")'), + }); + + const cfg = await loadSwiftPackageConfig(root); + + expect([...cfg!.targets.keys()]).toEqual(['New510']); + }); + + it('groups plugins but keeps them out of the import declaration map', async () => { + const root = repo({ 'Package.swift': pkg('.target(name: "Lib")', '.plugin(name: "Gen")') }); + + const cfg = await loadSwiftPackageConfig(root); + + expect(cfg?.targets.get('Gen')).toBe('Plugins/Gen'); + expect([...cfg!.declaredTargets!.keys()]).toEqual(['Lib']); + }); +}); + +describe('loadSwiftWorkspaceConfig — nested packages and Xcode projects (#3355)', () => { + it('adds each nested package target keyed by its repo-relative directory', async () => { + const root = repo({ + 'Core/Net/Package.swift': pkg('.target(name: "Net")'), + 'Core/Net/Sources/Net/Client.swift': '', + 'Features/Login/Package.swift': pkg( + '.target(name: "Login")', + '.testTarget(name: "LoginTests")', + ), + 'Features/Login/Sources/Login/View.swift': '', + 'Features/Login/Tests/LoginTests/ViewTests.swift': '', + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(cfg?.origin).toBe('directories'); + expect(moduleKeys(cfg)).toEqual([ + 'Core/Net/Sources/Net', + 'Features/Login/Sources/Login', + 'Features/Login/Tests/LoginTests', + ]); + expect(cfg?.modules?.find((m) => m.key === 'Core/Net/Sources/Net')).toMatchObject({ + name: 'Net', + importable: true, + }); + expect(cfg?.moduleNamesComplete).toBe(true); + expect(coerceDeclaredSwiftTargets(cfg)).toBeNull(); + }); + + it('keeps two packages declaring the same target name as two modules', async () => { + const root = repo({ + 'Core/A/Package.swift': pkg('.target(name: "Core")'), + 'Core/B/Package.swift': pkg('.target(name: "Core")'), + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + const groups = groupSwiftFilesByModule( + ['Core/A/Sources/Core/X.swift', 'Core/B/Sources/Core/Y.swift'], + (p) => p, + cfg, + ); + + expect(groups.get('Core/A/Sources/Core')).toEqual(['Core/A/Sources/Core/X.swift']); + expect(groups.get('Core/B/Sources/Core')).toEqual(['Core/B/Sources/Core/Y.swift']); + }); + + it('groups a nested file under its own package even when a root target shares the tail', async () => { + const root = repo({ + 'Package.swift': pkg('.target(name: "Core")'), + 'Features/A/Package.swift': pkg('.target(name: "Core")'), + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + const groups = groupSwiftFilesByModule( + ['Sources/Core/Root.swift', 'Features/A/Sources/Core/Nested.swift'], + (p) => p, + cfg, + ); + + expect(groups.get('Sources/Core')).toEqual(['Sources/Core/Root.swift']); + expect(groups.get('Features/A/Sources/Core')).toEqual(['Features/A/Sources/Core/Nested.swift']); + }); + + it('keeps the root declaration for legacy callers and lists every module', async () => { + const root = repo({ + 'Package.swift': MODELS_APP, + 'Features/Login/Package.swift': pkg('.target(name: "Login")'), + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(cfg?.origin).toBe('package.swift'); + expect([...coerceDeclaredSwiftTargets(cfg)!.keys()].sort()).toEqual(['App', 'Models']); + expect(moduleKeys(cfg)).toEqual([ + 'Features/Login/Sources/Login', + 'Sources/App', + 'Sources/Models', + ]); + }); + + it('joins a custom path with the package directory and drops one escaping the repo', async () => { + const root = repo({ + 'Pkgs/Lib/Package.swift': pkg( + '.target(name: "Lib", path: "Code")', + '.target(name: "Shared", path: "../Shared")', + '.target(name: "Outside", path: "../../../Outside")', + '.target(name: "Root", path: "../..")', + '.target(name: "Drive", path: "C:/Elsewhere")', + ), + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(moduleKeys(cfg)).toEqual(['Pkgs/Lib/Code', 'Pkgs/Shared']); + }); + + it('infers Sources/* folders for a hazardous nested manifest and marks names incomplete', async () => { + const root = repo({ + 'Core/Hazard/Package.swift': '#if os(Linux)\n.target(name: "L")\n#endif\n', + 'Core/Hazard/Sources/App/main.swift': '', + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(moduleKeys(cfg)).toEqual(['Core/Hazard/Sources/App']); + expect(cfg?.moduleNamesComplete).toBe(false); + }); + + it('skips manifests under build output, dependencies, and bundle directories', async () => { + const root = repo({ + '.build/checkouts/Dep/Package.swift': pkg('.target(name: "Dep")'), + 'node_modules/x/Package.swift': pkg('.target(name: "X")'), + 'App.xcodeproj/Package.swift': pkg('.target(name: "Proj")'), + 'Assets.xcassets/Package.swift': pkg('.target(name: "Assets")'), + 'App.xcworkspace/Package.swift': pkg('.target(name: "Ws")'), + 'en.lproj/Package.swift': pkg('.target(name: "Loc")'), + 'Res.bundle/Package.swift': pkg('.target(name: "Res")'), + 'Core/Real/Package.swift': pkg('.target(name: "Real")'), + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(moduleKeys(cfg)).toEqual(['Core/Real/Sources/Real']); + }); + + it('reads Xcode target membership and marks names incomplete when a project is unreadable', async () => { + const root = repo({ + 'App/App.xcodeproj/project.pbxproj': PBXPROJ, + 'App/Broken.xcodeproj/project.pbxproj': '{ objects = {', + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(cfg?.modules?.find((m) => m.name === 'App')).toMatchObject({ + key: 'xcode:App/App.xcodeproj:App', + files: ['App/App/AppMain.swift', 'App/Shared/Util.swift', 'App/Widget/Shared.swift'], + }); + expect(cfg?.moduleNamesComplete).toBe(false); + }); + + it('warns and keeps shallower packages when the walk passes the depth cap', async () => { + const deep = Array.from({ length: 26 }, (_, i) => `d${i}`).join('/'); + const root = repo({ + 'Core/Shallow/Package.swift': pkg('.target(name: "Shallow")'), + [`${deep}/Package.swift`]: pkg('.target(name: "Deep")'), + }); + + const cap = _captureLogger(); + try { + const cfg = await loadSwiftWorkspaceConfig(root); + expect(moduleKeys(cfg)).toEqual(['Core/Shallow/Sources/Shallow']); + expect(cfg?.moduleNamesComplete).toBe(false); + expect(cap.text()).toContain('workspace scan'); + } finally { + cap.restore(); + } + }); + + it('returns null with no manifest, project, or source folders anywhere', async () => { + const root = repo({ 'README.md': '' }); + expect(await loadSwiftWorkspaceConfig(root)).toBeNull(); + }); +}); + +describe('parseXcodeProject', () => { + it('resolves group paths, SOURCE_ROOT references, and synchronized folders with exceptions', () => { + const parsed = parseXcodeProject(PBXPROJ, 'App'); + + expect(parsed.complete).toBe(true); + expect(parsed.targets).toEqual([ + { + name: 'App', + moduleName: 'App', + files: ['App/App/AppMain.swift', 'App/Shared/Util.swift', 'App/Widget/Shared.swift'], + folders: [], + excluded: [], + }, + { + name: 'Widget', + moduleName: 'WidgetKitExt', + files: ['App/Shared/Util.swift'], + folders: ['App/Widget'], + excluded: ['App/Widget/Preview.swift'], + }, + ]); + }); + + it('reports an unparseable project as incomplete', () => { + expect(parseXcodeProject('{ objects = ', '')).toEqual({ targets: [], complete: false }); + }); + + it.each(['\\U12xz', '\\Uzzzz', '\\U12'])( + 'reports a malformed %s escape as incomplete', + (escape) => { + const project = `{ objects = { R = { isa = PBXProject; mainGroup = G; targets = ( ); }; G = { isa = PBXGroup; name = "${escape}"; }; }; rootObject = R; }`; + expect(parseXcodeProject(project, '').complete).toBe(false); + }, + ); +}); + +/** + * Two targets: `App` via a classic sources phase (one `` file, one + * `SOURCE_ROOT` file, one SDK framework that must be ignored), and `Widget` + * via an Xcode 16 synchronized folder, plus the shared SOURCE_ROOT file. + * The folder's exceptions remove `Preview.swift` from `Widget` (which lists + * the folder) and add `Shared.swift` to `App` (which does not). `Widget` + * sets a literal `PRODUCT_MODULE_NAME`. + */ +const PBXPROJ = `// !$*UTF8*$! +{ + archiveVersion = 1; + objectVersion = 77; + objects = { + ROOT /* Project object */ = { isa = PBXProject; mainGroup = MAIN; projectDirPath = ""; targets = ( TAPP, TWID, ); }; + MAIN = { isa = PBXGroup; children = ( GAPP, FUTIL, FSDK, SYNC, ); sourceTree = ""; }; + GAPP /* App */ = { isa = PBXGroup; children = ( FMAIN, ); path = App; sourceTree = ""; }; + FMAIN = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = ""; }; + FUTIL = { isa = PBXFileReference; path = "Shared/Util.swift"; sourceTree = SOURCE_ROOT; }; + FSDK = { isa = PBXFileReference; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + SYNC = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( EXC, EXCAPP, ); path = Widget; sourceTree = ""; }; + EXC = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Preview.swift, ); target = TWID; }; + EXCAPP = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Shared.swift, ); target = TAPP; }; + TAPP = { isa = PBXNativeTarget; buildPhases = ( PAPP, ); name = App; }; + PAPP = { isa = PBXSourcesBuildPhase; files = ( BMAIN, BUTIL, BSDK, ); }; + BMAIN = { isa = PBXBuildFile; fileRef = FMAIN; }; + BUTIL = { isa = PBXBuildFile; fileRef = FUTIL; }; + BSDK = { isa = PBXBuildFile; fileRef = FSDK; }; + TWID = { isa = PBXNativeTarget; buildConfigurationList = CLWID; buildPhases = ( PWID, ); fileSystemSynchronizedGroups = ( SYNC, ); name = Widget; }; + CLWID = { isa = XCConfigurationList; buildConfigurations = ( CDEBUG, ); }; + CDEBUG = { isa = XCBuildConfiguration; buildSettings = { PRODUCT_MODULE_NAME = WidgetKitExt; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; }; + PWID = { isa = PBXSourcesBuildPhase; files = ( BUTIL2, ); }; + BUTIL2 = { isa = PBXBuildFile; fileRef = FUTIL; }; + }; + rootObject = ROOT; +} +`; + +describe('loadSwiftWorkspaceConfig — compiler module names and filters (#3355)', () => { + it('names a SwiftPM module the way the compiler does and rebases sources:/exclude:', async () => { + const root = repo({ + 'Pkgs/Kit/Package.swift': pkg( + '.target(name: "my-kit", sources: ["Core"], exclude: ["Core/Legacy"])', + ), + 'Pkgs/Kit/Sources/my-kit/Core/A.swift': '', + }); + + const cfg = await loadSwiftWorkspaceConfig(root); + + expect(cfg?.modules).toEqual([ + { + key: 'Pkgs/Kit/Sources/my-kit', + name: 'my_kit', + dir: 'Pkgs/Kit/Sources/my-kit', + importable: true, + sources: ['Pkgs/Kit/Sources/my-kit/Core'], + excluded: ['Pkgs/Kit/Sources/my-kit/Core/Legacy'], + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts index 95d53bb43..5fcf8ec96 100644 --- a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts @@ -1,34 +1,29 @@ /** - * Drift-guard unit test for `groupSwiftFilesBySpmTarget` (issue #1948 U3, - * KTD2). - * - * `groupSwiftFilesBySpmTarget` (`languages/swift/target-grouping.ts`) - * preserves the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`) - * bucketing contract for ordinary SPM layouts: one target bucket per file, - * first-target-wins ordering, and the same `__default__` fallback. It now - * intentionally differs for issue #2931's repeated-prefix edge case by - * accepting a later segment-boundary occurrence when an earlier textual - * occurrence is embedded inside a longer path segment. These tests pin the - * shared ordinary-layout contract plus that documented #2931 fix. + * Unit tests for `groupSwiftFilesByModule` over hand-built `{ targets }` + * configs (issue #1948 U3, #3355). * * 1. A multi-subdir single target buckets into ONE group. - * 2. A file matching two overlapping same-named target prefixes is - * assigned to the FIRST target only (legacy `break`s — no fan-out). - * 3. Target dirs match only at path-segment boundaries. - * 4. Unmatched files AND the no-targets case route to `__default__` = all. + * 2. A file under two nested target dirs joins the DEEPEST only. + * 3. Target dirs match only at path-segment boundaries, from the repo root. + * 4. Unmatched files AND the no-targets case route to `__default__`. * * `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }` * (no `instanceof` on the config object) and returns `null` otherwise. */ import { describe, it, expect } from 'vitest'; import { - groupSwiftFilesBySpmTarget, + groupSwiftFilesByModule, coerceSwiftTargets, + swiftC99ModuleName, + swiftModuleKeysOf, } from '../../../../src/core/ingestion/languages/swift/target-grouping.js'; +/** A hand-built `{ targets }` config, as the root-only loader produces. */ +const cfg = (targets: Map | null) => (targets === null ? null : { targets }); + const id = (s: string) => s; -describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { +describe('groupSwiftFilesByModule — SwiftPM bucketing contract', () => { it('buckets a multi-subdir single target into ONE group', () => { const files = [ 'Sources/Alpha/Core/User.swift', @@ -37,33 +32,43 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { ]; const targets = new Map([['Alpha', 'Sources/Alpha']]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); expect([...groups.keys()]).toEqual(['Alpha']); expect(groups.get('Alpha')).toEqual(files); expect(groups.has('__default__')).toBe(false); }); - it('assigns a file matching two overlapping same-named prefixes to the FIRST target only', () => { - // Both targets are prefixes of the file's path (Beta dir nested under - // Alpha). The first configured match wins → one bucket per file. + it('assigns a file under two nested target dirs to the DEEPEST target only', () => { + // SwiftPM rejects overlapping target sources within one package, so this + // only arises across packages; the more specific directory is the module. const files = ['Sources/Alpha/Beta/User.swift']; const targets = new Map([ ['Alpha', 'Sources/Alpha'], ['Beta', 'Sources/Alpha/Beta'], ]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); - expect(groups.get('Alpha')).toEqual(files); - expect(groups.has('Beta')).toBe(false); + expect(groups.get('Beta')).toEqual(files); + expect(groups.has('Alpha')).toBe(false); + }); + + it('matches target dirs from the repo root, not further down the path', () => { + // A target path is relative to its package; the loader rebases nested + // packages, so a vendored copy of the same layout is not this target. + const files = ['Vendor/Copy/Sources/Alpha/User.swift']; + + const groups = groupSwiftFilesByModule(files, id, cfg(new Map([['Alpha', 'Sources/Alpha']]))); + + expect(groups.get('__default__')).toEqual(files); }); it('assigns root-level files to a path: "." target', () => { const files = ['Lib.swift', 'Sources/Other/X.swift']; const targets = new Map([['Lib', '.']]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); expect(groups.get('Lib')).toEqual(files); expect(groups.has('__default__')).toBe(false); @@ -75,7 +80,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { const files = ['Sources/AlphaBeta/User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); expect(groups.has('Alpha')).toBe(false); expect(groups.get('__default__')).toEqual(files); @@ -85,7 +90,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { const files = ['Sources/Alpha/User.swift', 'Loose/Orphan.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); expect(groups.get('Alpha')).toEqual(['Sources/Alpha/User.swift']); expect(groups.get('__default__')).toEqual(['Loose/Orphan.swift']); @@ -94,7 +99,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { it('routes ALL files to __default__ when targets is null (no source dir found)', () => { const files = ['Models/User.swift', 'Services/App.swift']; - const groups = groupSwiftFilesBySpmTarget(files, id, null); + const groups = groupSwiftFilesByModule(files, id, cfg(null)); expect([...groups.keys()]).toEqual(['__default__']); expect(groups.get('__default__')).toEqual(files); @@ -103,7 +108,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { it('routes ALL files to __default__ when targets is empty', () => { const files = ['Models/User.swift', 'Services/App.swift']; - const groups = groupSwiftFilesBySpmTarget(files, id, new Map()); + const groups = groupSwiftFilesByModule(files, id, cfg(new Map())); expect([...groups.keys()]).toEqual(['__default__']); expect(groups.get('__default__')).toEqual(files); @@ -119,7 +124,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { ['Beta', 'Sources/Beta'], ]); - const groups = groupSwiftFilesBySpmTarget(items, (i) => i.filePath, targets); + const groups = groupSwiftFilesByModule(items, (i) => i.filePath, cfg(targets)); expect(groups.get('Alpha')).toEqual([items[0]]); expect(groups.get('Beta')).toEqual([items[1]]); @@ -137,7 +142,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { 'Sources/Foundation/Thing.swift', ]; - const groups = groupSwiftFilesBySpmTarget(items, id, targets); + const groups = groupSwiftFilesByModule(items, id, cfg(targets)); expect(groups.get('App')).toEqual(['Sources/App/main.swift']); expect(groups.get('Models')).toEqual(['Sources/Models/User.swift']); @@ -149,7 +154,7 @@ describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { const files = ['Sources\\Alpha\\Core\\User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); - const groups = groupSwiftFilesBySpmTarget(files, id, targets); + const groups = groupSwiftFilesByModule(files, id, cfg(targets)); expect(groups.get('Alpha')).toEqual(files); }); @@ -169,3 +174,77 @@ describe('coerceSwiftTargets — duck-type the opaque resolutionConfig', () => { expect(coerceSwiftTargets({ goModule: { modulePath: 'x' } })).toBeNull(); }); }); + +describe('swiftModuleKeysOf — compiler module rules (#3355)', () => { + const modules = [ + { + key: 'Pkg/Sources/Lib', + name: 'Lib', + dir: 'Pkg/Sources/Lib', + importable: true, + sources: ['Pkg/Sources/Lib/Core', 'Pkg/Sources/Lib/Main.swift'], + excluded: ['Pkg/Sources/Lib/Core/Legacy'], + }, + ]; + const complete = { targets: new Map(), modules, moduleNamesComplete: true }; + const partial = { targets: new Map(), modules, moduleNamesComplete: false }; + + it('makes every package manifest a module of its own', () => { + for (const manifest of ['Package.swift', 'Pkg/Package.swift', 'Pkg/Package@swift-5.9.swift']) { + expect(swiftModuleKeysOf(manifest, complete)).toEqual([`file:${manifest}`]); + expect(swiftModuleKeysOf(manifest, null)).toEqual([`file:${manifest}`]); + } + }); + + it('applies sources: and exclude:, leaving filtered-out files in no target', () => { + expect(swiftModuleKeysOf('Pkg/Sources/Lib/Core/A.swift', complete)).toEqual([ + 'Pkg/Sources/Lib', + ]); + expect(swiftModuleKeysOf('Pkg/Sources/Lib/Main.swift', complete)).toEqual(['Pkg/Sources/Lib']); + expect(swiftModuleKeysOf('Pkg/Sources/Lib/Other.swift', complete)).toEqual([ + 'file:Pkg/Sources/Lib/Other.swift', + ]); + expect(swiftModuleKeysOf('Pkg/Sources/Lib/Core/Legacy/Old.swift', complete)).toEqual([ + 'file:Pkg/Sources/Lib/Core/Legacy/Old.swift', + ]); + }); + + it('keeps leftovers apart only when every manifest and project was read', () => { + expect(swiftModuleKeysOf('Scripts/tool.swift', complete)).toEqual(['file:Scripts/tool.swift']); + expect(swiftModuleKeysOf('Scripts/tool.swift', partial)).toEqual(['__default__']); + }); +}); + +describe('swiftC99ModuleName', () => { + it.each([ + ['Lib', 'Lib'], + ['my-lib', 'my_lib'], + ['Widget Extension', 'Widget_Extension'], + ['3D', '_3D'], + ['Café.Kit', 'Café_Kit'], + ])('%s -> %s', (name, expected) => { + expect(swiftC99ModuleName(name)).toBe(expected); + }); +}); + +describe('swiftModuleKeysOf — review fixes (#3355)', () => { + it('keeps both a SwiftPM target and an Xcode target that compile the same file', () => { + const config = { + targets: new Map(), + modules: [ + { key: 'Pkg/Sources/Lib', name: 'Lib', dir: 'Pkg/Sources/Lib', importable: true }, + { + key: 'xcode:App.xcodeproj:App', + name: 'App', + files: ['Pkg/Sources/Lib/Shared.swift'], + importable: true, + }, + ], + moduleNamesComplete: true, + }; + expect(swiftModuleKeysOf('Pkg/Sources/Lib/Shared.swift', config)).toEqual([ + 'Pkg/Sources/Lib', + 'xcode:App.xcodeproj:App', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts index eef725014..e600eba86 100644 --- a/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts @@ -3,10 +3,13 @@ import { type ParsedFile, type ScopeId, type SymbolDefinition, + type TypeRef, } from 'gitnexus-shared'; import { describe, expect, it } from 'vitest'; import { populateSwiftTargetSiblings } from '../../../../src/core/ingestion/languages/swift/target-siblings.js'; +import { mirrorSwiftSiblingTypeBindings } from '../../../../src/core/ingestion/languages/swift/sibling-type-bindings.js'; import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { WorkspaceResolutionIndex } from '../../../../src/core/ingestion/scope-resolution/workspace-index.js'; const moduleId = (filePath: string) => `scope:${filePath}:module` as ScopeId; const classId = (filePath: string) => `scope:${filePath}:class` as ScopeId; @@ -323,6 +326,8 @@ function makeIndexes( ]), }, bindingAugmentations, + namespaceFqnBindings: new Map(), + accessibleNamespacesByScope: new Map(), } as unknown as ScopeResolutionIndexes; } @@ -389,3 +394,105 @@ function qualifiedExtensionFixture( const indexes = makeIndexes([owner, entry, makeEntry, ...extraDefs], bindingAugmentations); return { declaration, extension, entry, indexes, bindingAugmentations }; } + +describe('Swift sibling passes — one shared table per module (#3355)', () => { + const stub = (filePath: string, typeBindings = new Map()): ParsedFile => ({ + filePath, + moduleScope: moduleId(filePath), + scopes: [ + { + id: moduleId(filePath), + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings, + }, + ], + parsedImports: [], + localDefs: [ + { + nodeId: `def:${filePath}:T`, + filePath, + type: 'Class', + qualifiedName: `T${filePath.length}`, + }, + ], + referenceSites: [], + }); + const siblingIndexes = (files: readonly ParsedFile[]): ScopeResolutionIndexes => + ({ + moduleScopes: { byFilePath: new Map(files.map((f) => [f.filePath, f.moduleScope])) }, + bindingAugmentations: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), + }) as unknown as ScopeResolutionIndexes; + const targets = { + targets: new Map([ + ['A', 'Sources/A'], + ['B', 'Sources/B'], + ]), + }; + + it('puts every member def in one table and grants each member access to it', () => { + const files = ['Sources/A/X.swift', 'Sources/A/YY.swift', 'Sources/B/Z.swift'].map((p) => + stub(p), + ); + const indexes = siblingIndexes(files); + + populateSwiftTargetSiblings(files, indexes, { + fileContents: new Map(), + resolutionConfig: targets, + }); + + expect([...indexes.namespaceFqnBindings.keys()]).toEqual(['swift-module:A']); + expect([...indexes.namespaceFqnBindings.get('swift-module:A')!.keys()].sort()).toEqual([ + 'T17', + 'T18', + ]); + expect(indexes.accessibleNamespacesByScope.get(moduleId('Sources/A/X.swift'))).toEqual([ + 'swift-module:A', + ]); + expect(indexes.accessibleNamespacesByScope.has(moduleId('Sources/B/Z.swift'))).toBe(false); + expect(indexes.bindingAugmentations.size).toBe(0); + }); + + it('stays linear: one binding per def regardless of module size', () => { + const files = Array.from({ length: 200 }, (_, i) => stub(`Sources/A/F${i}.swift`)); + const indexes = siblingIndexes(files); + + populateSwiftTargetSiblings(files, indexes, { + fileContents: new Map(), + resolutionConfig: targets, + }); + + const table = indexes.namespaceFqnBindings.get('swift-module:A')!; + const bindings = [...table.values()].reduce((n, refs) => n + refs.length, 0); + expect(bindings).toBe(200); + }); + + it('mirrors sibling type bindings into the shared type table, first declaration wins', () => { + const first = stub('Sources/A/First.swift', new Map([['make', { rawName: 'Box' } as TypeRef]])); + const second = stub( + 'Sources/A/Second.swift', + new Map([['make', { rawName: 'Crate' } as TypeRef]]), + ); + const indexes = siblingIndexes([first, second]); + const workspace = { + moduleScopeByFile: new Map([first, second].map((f) => [f.filePath, f.scopes[0]!] as const)), + } as unknown as WorkspaceResolutionIndex; + Object.assign(indexes, { scopeTree: { getScope: () => undefined } }); + + mirrorSwiftSiblingTypeBindings([first, second], indexes, workspace, targets); + + expect(indexes.namespaceTypeBindings.get('swift-module:A')?.get('make')?.rawName).toBe('Box'); + expect(second.scopes[0]!.typeBindings.get('make')?.rawName).toBe('Crate'); + expect(indexes.accessibleNamespacesByScope.get(moduleId('Sources/A/Second.swift'))).toEqual([ + 'swift-module:A', + ]); + }); +});