mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
fix(swift): model Swift modules like the compiler (nested packages, Xcode targets, linear visibility) (#3387)
* fix(swift): discover nested Package.swift manifests for module grouping A Swift monorepo laid out as Core/<pkg>/Package.swift has no root manifest and no root Sources/, so every Swift file fell into one __default__ module. The Swift resolver now walks the repo (bounded, skipping dot, ignored, and Xcode bundle directories) and adds each nested package's targets, keyed by repo-relative directory and ordered deepest-first so first-match grouping picks the most specific target. Import resolution keeps the root view. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(swift): bound implicit IMPORTS edges by a total budget Implicit same-module IMPORTS are n*(n-1) edges per module, and the graph keeps every relationship in one Map capped by V8 at 2^24 entries. Emission now fills a 4M-edge budget smallest module first and skips, with a warning, any module that does not fit, so no module layout can crash analyze. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(swift): skip pairwise sibling passes for oversized modules The target-siblings and sibling-type-bindings passes copy every file's declarations into every other file of a module, so heap grows as n^2: about 3.8 GB at 1,000 files with 15 defs each, about 15 GB at 2,000. Modules over 1,000 files now skip both passes with a warning and resolve through the global name fallback. GITNEXUS_SWIFT_MAX_MODULE_FILES changes the ceiling. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(swift): cover nested SwiftPM packages end to end A fixture with three nested Package.swift manifests (two declaring a target named Net) and no root manifest. Each target gets its own implicit IMPORTS, none cross packages, and Config() resolves to the caller's own package. The Swift capture golden and scope-capture fingerprint grow with the new fixture corpus only. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(review): apply review findings - Rebase nested target paths with the existing Zig path helpers, which also reject Windows drive paths and paths that resolve to the repo root (whose empty prefix would group every file into one target). - Document GITNEXUS_SWIFT_MAX_MODULE_FILES in the README env table. - State that skipped modules resolve through lower-confidence fallback edges, why nested-type fragments still run for oversized modules, and fix a stale loader name in the target-grouping header. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(swift): cover every skipped Xcode bundle suffix in the package walk Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(swift): model modules the way the compiler does Replace the caps from the first round with representations that stay linear, and derive module identity from the same sources the compiler uses. - Same-module visibility is one File -> Module IMPORTS edge per file (reason `module-membership`) instead of an edge per ordered file pair. Incremental importer expansion treats files sharing a Module hub as importers of each other. The 4M edge budget is gone. - Sibling declarations and type bindings live in one shared table per module in the namespace channel C# uses since #1871, instead of being copied into every file. The 1,000-file ceiling and GITNEXUS_SWIFT_MAX_MODULE_FILES are gone. - Modules come from root and nested SwiftPM manifests (Sources, Source, src, srcs; plugins under Plugins; the newest Package@swift-X.Y.swift) and from Xcode native targets in project.pbxproj, including Xcode 16 synchronized folders. Target paths are matched from the repo root, so a vendored copy of the same layout is no longer grouped into a root target (this reverses #2931's floating match). - `import X` resolves to the modules named X instead of any folder named X. A name no module carries is external when every manifest and project was read completely. - The global-name-fallback veto uses the same membership, so test targets, custom-path targets and Xcode targets have module identity. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(bench): move the Swift package bench to module grouping The bench still imported the removed groupSwiftFilesBySpmTarget. Its first-wins probe keeps its meaning under root-anchored grouping: the clash file sits under Sources/Mod0, and the Sources/Mod1 further down its path is a vendored copy. Plugins are now non-importable modules under Plugins/, so parse_targets counts importable source targets (still 3) and parse_binary_skipped also checks that the plugin is recorded that way. Baseline values unchanged; the notes say why the definitions moved. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(swift): match compiler module names, manifests, and target filters - Module names follow the compiler's c99 mangling (`my-lib` imports as `my_lib`); Xcode targets use a literal PRODUCT_MODULE_NAME or PRODUCT_NAME when the project sets one. - Package manifests are one-file modules, as SwiftPM compiles them. Files outside every target are one-file modules once every manifest and project was read; otherwise they keep the shared __default__ module. - Xcode 16 synchronized-folder exceptions add a file to a target that does not list the folder, and remove it from one that does. - SwiftPM `sources:` / `exclude:` narrow a target; a computed list marks the manifest unreadable. - The default target folder is chosen once per package, as SwiftPM does, instead of per target. Refs #3355 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address PR review feedback (#3387) - Inferred Swift folders keep SwiftPM's first predefined parent when a target name repeats (Sources before srcs). - The pbxproj parser rejects a \U escape without four hex digits instead of decoding garbage, so the project reads as incomplete. - Extension owners are stamped in every Xcode membership of a shared file. - A plugin name never reaches a plugin: neither the fallback veto nor the folder-index fallback treats a known non-importable module as imported. - A file an Xcode target compiles keeps that membership even when it also lies under a SwiftPM target directory. - The workspace scan bounds the queue, not only the directories read. - Integration tests require each module hub to exist before comparing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0261982d9a
commit
51fb64c976
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