mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
Merge branch 'main' into feat/3371-ipynb-python-indexing
This commit is contained in:
commit
d2e51c144e
43 changed files with 2643 additions and 410 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
189
gitnexus/src/core/ingestion/languages/swift/workspace-config.ts
Normal file
189
gitnexus/src/core/ingestion/languages/swift/workspace-config.ts
Normal 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 };
|
||||
}
|
||||
334
gitnexus/src/core/ingestion/languages/swift/xcode-project.ts
Normal file
334
gitnexus/src/core/ingestion/languages/swift/xcode-project.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Package.swift
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Core/Net/Package.swift
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Net",
|
||||
targets: [
|
||||
.target(name: "Net"),
|
||||
]
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class Client {
|
||||
func send() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
func openSession() {
|
||||
let client = Client()
|
||||
client.send()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Docs/Net/Snippet.swift
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Docs/Net/Snippet.swift
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Client {
|
||||
func send() {}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Package.swift
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Login/Package.swift
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Login",
|
||||
targets: [
|
||||
.target(name: "Login"),
|
||||
]
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class Config {
|
||||
func load() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import Net
|
||||
|
||||
func connect() {
|
||||
let client = Client()
|
||||
client.send()
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
func startLogin() {
|
||||
let config = Config()
|
||||
config.load()
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Package.swift
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/swift-nested-packages/Features/Other/Package.swift
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Other",
|
||||
targets: [
|
||||
.target(name: "Net"),
|
||||
]
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class Config {
|
||||
func load() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
func useOther() {
|
||||
let config = Config()
|
||||
config.load()
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/AppMain.swift
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/AppMain.swift
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
func startApp() {
|
||||
let config = Config()
|
||||
config.load()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/Config.swift
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/App/Config.swift
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Config {
|
||||
func load() {}
|
||||
}
|
||||
102
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Demo.xcodeproj/project.pbxproj
vendored
Normal file
102
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Demo.xcodeproj/project.pbxproj
vendored
Normal 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 */;
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/Config.swift
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/Config.swift
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Config {
|
||||
func load() {}
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/WidgetMain.swift
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/swift-xcode-targets/Widget/WidgetMain.swift
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
func startWidget() {
|
||||
let config = Config()
|
||||
config.load()
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue