Merge branch 'main' into feat/3371-ipynb-python-indexing

This commit is contained in:
Gergő Magyar 2026-09-27 06:25:33 +01:00 • committed by GitHub
commit d2e51c144e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2643 additions and 410 deletions

View file

@ -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.",

View file

@ -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.",

View file

@ -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'),

View file

@ -36,3 +36,16 @@ const HEURISTIC_EDGE_REASONS: ReadonlySet<string> = 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';

View file

@ -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';

View file

@ -198,6 +198,44 @@ export interface SwiftPackageConfig {
* binary-only Package.swift does not collapse every file into `__default__`.
*/
declaredTargets?: Map<string, string>;
/**
* 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<string, SwiftTargetFilter>;
}
/** One Swift module (compiler unit) found in the workspace. */
export interface SwiftModuleSpec {
/** Stable key: a repo-relative SwiftPM target directory, or `xcode:<project>:<target>`. */
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<string>(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<string, string>;
/**
* 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<string, SwiftTargetDirKind>;
/** Plugin targets: modules for grouping, never `import`-able. */
plugins: Set<string>;
/** `sources:` / `exclude:` per target name, relative to the target directory. */
filters: Map<string, SwiftTargetFilter>;
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<string, string>();
const implicitDirs = new Map<string, SwiftTargetDirKind>();
const plugins = new Set<string>();
const filters = new Map<string, SwiftTargetFilter>();
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<Record<SwiftTargetDirKind, string>> = {
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<Record<SwiftTargetDirKind, readonly string[]>> = {
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<Map<string, string>> {
async function inferSwiftDirectoryTargets(
repoRoot: string,
packageDir = '',
): Promise<Map<string, string>> {
const targets = new Map<string, string>();
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<Map<string,
return targets;
}
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
/**
* 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<SwiftPackageConfig | null> {
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<SwiftTargetDirKind, string | null>();
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<SwiftPac
return null;
}
const SWIFT_VERSIONED_MANIFEST_RE = /^Package@swift-(\d+)(?:\.(\d+))?(?:\.(\d+))?\.swift$/;
/**
* The manifest SwiftPM would read in `pkgRoot`. A `Package@swift-X.Y.swift`
* overrides `Package.swift` for toolchains at or above X.Y; the toolchain is
* unknown here, so assume the newest and take the highest version present.
*/
async function pickSwiftManifestFile(pkgRoot: string): Promise<string> {
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<string | null> {
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<string[]> {
* 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);
}

View file

@ -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/<Target>/…`), 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 '.';
}

View file

@ -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<string>): Swif
const SWIFT_DECLARED_INDEX = new WeakMap<ReadonlySet<string>, SwiftDeclaredFileIndex>();
const SWIFT_MODULE_NAME_INDEX = new WeakMap<
ReadonlySet<string>,
{ readonly config: object; readonly byName: ReadonlyMap<string, string[]> }
>();
/** Workspace-module config, or null for a hand-built or root-only config. */
function workspaceModulesConfig(resolutionConfig: unknown): Partial<SwiftPackageConfig> | null {
const config = resolutionConfig as Partial<SwiftPackageConfig> | null | undefined;
return config != null && Array.isArray(config.modules) ? config : null;
}
function isKnownModuleName(config: Partial<SwiftPackageConfig>, 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<string>,
config: object,
): ReadonlyMap<string, string[]> {
const hit = SWIFT_MODULE_NAME_INDEX.get(allFilePaths);
if (hit !== undefined && hit.config === config) return hit.byName;
const byName = new Map<string, string[]>();
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<string>,
declared: ReadonlyMap<string, string>,
@ -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);

View file

@ -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';

View file

@ -9,10 +9,13 @@
* module is reachable only if the caller wrote `import <ThatModule>`, 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/<Target>/…` (also under `Package/`).
* `src/<Target>/…` 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/<Target>/…` or `src/<Target>/…` 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<string> = 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<string>();
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;
}

View file

@ -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/<Target>/…`) 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/<Target>/ 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<string, string>; 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);

View file

@ -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/<Target>/…`): 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:<key>`), 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<string, Map<string, TypeRef>>;
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<string, TypeRef>).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);
}
}

View file

@ -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/<Target>/…`), 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 `<targetDir>/` or contains `/<targetDir>/` 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<T>(
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<string, string>;
readonly xcodeByFile: ReadonlyMap<string, readonly string[]>;
readonly xcodeByFolder: ReadonlyMap<
string,
readonly { key: string; excluded: readonly string[] }[]
>;
readonly specByKey: ReadonlyMap<string, SwiftModuleSpec>;
readonly order: ReadonlyMap<string, number>;
/** Leftover files are one-file modules (see the file header). */
readonly leftoversStandalone: boolean;
}
const MATCHERS = new WeakMap<object, SwiftModuleMatcher>();
/**
* 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<SwiftPackageConfig> | 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<string>();
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<T>(
items: readonly T[],
getPath: (item: T) => string,
targets: ReadonlyMap<string, string> | null,
resolutionConfig: unknown,
options: { readonly allMemberships?: boolean } = {},
): Map<string, T[]> {
// 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<string, T[]>();
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<string,string>`.
* 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<string, string> | null {
const config = resolutionConfig as Partial<SwiftPackageConfig> | null | undefined;
@ -96,12 +173,66 @@ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap<strin
return null;
}
function pathMatchesTargetPrefix(normalizedPath: string, prefix: string): boolean {
if (prefix === '') return true;
return normalizedPath.startsWith(prefix) || normalizedPath.includes(`/${prefix}`);
const DEFAULT_KEYS: readonly string[] = Object.freeze([DEFAULT_SWIFT_MODULE]);
function matcherFor(resolutionConfig: unknown): SwiftModuleMatcher | null {
if (typeof resolutionConfig !== 'object' || resolutionConfig === null) return null;
const cached = MATCHERS.get(resolutionConfig);
if (cached !== undefined) return cached;
const specs = swiftModuleSpecs(resolutionConfig);
if (specs === null) return null;
const spmByDir = new Map<string, string>();
const xcodeByFile = new Map<string, string[]>();
const xcodeByFolder = new Map<string, { key: string; excluded: readonly string[] }[]>();
const specByKey = new Map<string, SwiftModuleSpec>();
const order = new Map<string, number>();
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<SwiftPackageConfig>).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;
}

View file

@ -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/<Target>/…`). 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:<key>`, 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<ScopeId, string[]>;
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<ScopeId, Map<string, BindingRef[]>>;
for (const group of groupSwiftFilesByModule(
parsedFiles,
(parsed) => parsed.filePath,
targets,
);
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
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<string, Map<string, BindingRef[]>>;
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<string, BindingRef[]>, 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, Map<string, BindingRef[]>>,
scopeId: ScopeId,

View file

@ -0,0 +1,189 @@
/**
* Swift workspace module discovery (#3355).
*
* `loadSwiftPackageConfig` reads only the root package. A monorepo laid out as
* `Core/<pkg>/Package.swift` has no root manifest and no root `Sources/`, and
* an Xcode app keeps its targets in `<App>.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<SwiftPackageConfig | null> {
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<string, string>();
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<SwiftWorkspaceScan> {
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 };
}

View file

@ -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 `<group>` 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 `<projectDir>/<Name>.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<string, string>();
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<string, string>();
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<string, Set<string>>();
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<string>();
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<string, XcodeTargetMembership>();
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 : '<group>';
let anchor: string | null;
if (tree === '<group>') 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;
}
}
}

View file

@ -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 <predicate>` 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<string[]>
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<string>();
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<string>();
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

View file

@ -0,0 +1,9 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "Net",
targets: [
.target(name: "Net"),
]
)

View file

@ -0,0 +1,3 @@
class Client {
func send() {}
}

View file

@ -0,0 +1,4 @@
func openSession() {
let client = Client()
client.send()
}

View file

@ -0,0 +1,3 @@
class Client {
func send() {}
}

View file

@ -0,0 +1,9 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "Login",
targets: [
.target(name: "Login"),
]
)

View file

@ -0,0 +1,3 @@
class Config {
func load() {}
}

View file

@ -0,0 +1,6 @@
import Net
func connect() {
let client = Client()
client.send()
}

View file

@ -0,0 +1,4 @@
func startLogin() {
let config = Config()
config.load()
}

View file

@ -0,0 +1,9 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "Other",
targets: [
.target(name: "Net"),
]
)

View file

@ -0,0 +1,3 @@
class Config {
func load() {}
}

View file

@ -0,0 +1,4 @@
func useOther() {
let config = Config()
config.load()
}

View file

@ -0,0 +1,4 @@
func startApp() {
let config = Config()
config.load()
}

View file

@ -0,0 +1,3 @@
class Config {
func load() {}
}

View file

@ -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 = "<group>"; };
A2000002 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
A2000003 /* WidgetMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetMain.swift; sourceTree = "<group>"; };
A2000004 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
A3000000 = {
isa = PBXGroup;
children = (
A3000001 /* App */,
A3000002 /* Widget */,
);
sourceTree = "<group>";
};
A3000001 /* App */ = {
isa = PBXGroup;
children = (
A2000001 /* AppMain.swift */,
A2000002 /* Config.swift */,
);
path = App;
sourceTree = "<group>";
};
A3000002 /* Widget */ = {
isa = PBXGroup;
children = (
A2000003 /* WidgetMain.swift */,
A2000004 /* Config.swift */,
);
path = Widget;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,3 @@
class Config {
func load() {}
}

View file

@ -0,0 +1,4 @@
func startWidget() {
let config = Config()
config.load()
}

View file

@ -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"

View file

@ -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', () => {

View file

@ -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`

View file

@ -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',

View file

@ -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();
});
});

View file

@ -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' },
});
});
});

View file

@ -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']);
});
});

View file

@ -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 `<group>` 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 = "<group>"; };
GAPP /* App */ = { isa = PBXGroup; children = ( FMAIN, ); path = App; sourceTree = "<group>"; };
FMAIN = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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'],
},
]);
});
});

View file

@ -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<string, string> | 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',
]);
});
});

View file

@ -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<string, TypeRef>()): 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',
]);
});
});