fix(swift): resolve imports from Package.swift targets, not path segments (#3105)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* fix(swift): match repeated SPM target prefixes

* test(swift): cover repeated SPM target prefixes

* test(swift): cover valid prefix before later partial match

* docs(swift): clarify target grouping parity scope

* docs(swift): clarify target grouping parity scope

* docs(swift): clarify target grouping parity scope

* fix(swift): resolve imports from Package.swift targets, not path segments

Stop fabricating IMPORTS from import Foundation onto a same-named folder.
Declare modules from Package.swift when the manifest is usable; keep
Sources/* for grouping and fail-open folder resolve minus SDK names.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): keep empty Package.swift declarations and nested .target() deps external

An inferred Sources/* folder is grouping-only. A dependency .target(name:) is not a module. Treat both as unresolved so import Foundation cannot bind to a decoy folder.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): keep implicit IMPORTS intra-group and gate linear Package.swift resolve

@_exported must not paint sibling files as implicit imports. A dedicated
bench pins declaration-only resolve and (t_4n/t_n)/4 linearity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Honor member-only @_exported imports, skip comments while scanning
Package.swift factories, fail-open mixed helper-built target lists, and
block CoreData/CoreGraphics decoy folders on the inferred path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Match path: "." as the package root, skip block-commented Package.swift
factories, read import kind from the clause only, and skip capture tests
when the optional Swift grammar is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): scan Swift import-kind without nested regex backtracking

CodeQL js/redos flagged the comment-skipping IMPORT_KIND_RE; a linear walk keeps the same kind tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): linear Package.swift factory scan and gate Swift context

parseSwiftPackageManifest re-walked every prefix for comments (O(n²) in factory count). Resume the scan and cover nested factories in one pass. Wire Swift into the import-target context arm now that resolveImportTarget is 5-arg.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Tighten Package.swift and import-text scanners: skip comments/strings, reject escapes, treat ident + [ as incomplete, and drop the unused factory-comment wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): prettier the @_exported availability fixture

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Judge Package.swift completeness from Package(...)'s own targets: argument instead of raw-text regexes, nest block comments when reading an import kind, and strip leading ./ from declared target paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Collect Package.swift factories only from Package(targets: [...]), fail-open on computed array elements, and treat // after a label colon as a comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Ignore stray factories when Package() exists but omits targets:.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3105)

Require the Package-scan seen box so the always-true undefined guard goes away.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Twisted_Arrow 2026-09-19 20:20:31 +05:30 • committed by GitHub
parent a578747455
commit dcb2eb5cb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2959 additions and 174 deletions

View file

@ -618,6 +618,17 @@ jobs:
run: node --import tsx bench/rust-cargo-targets/measure.mjs --check
working-directory: gitnexus
- name: Swift Package.swift import-resolve guards (#2964, #2931)
if: ${{ !cancelled() }}
# Build-free: same baseline approach as parse-dispatch-rounds —
# exact declared/SDK/undeclared floors plus a fingerprint, then
# ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy,
# parseSwiftPackageManifest 4n/n). Pins declaration-only resolve,
# https:// factory survival, and #2931 segment-boundary membership.
# See bench/swift-package-imports/measure.mjs.
run: node --import tsx bench/swift-package-imports/measure.mjs --check
working-directory: gitnexus
- name: MCP tools/list countRepos vs listRepos guards (#3259, #3184)
if: ${{ !cancelled() }}
# Build-free: exact registry cardinality + tool-roster + schema-flag

View file

@ -659,6 +659,11 @@
"path_segments": 13,
"probe": "ExternalPkg0"
},
"context": {
"target": "App",
"with_context": "Sources/App/Lib.swift,Sources/Models/User.swift",
"without_context": "Sources/App/Lib.swift"
},
"_measured": {
"collide_ms": 0.819,
"collide_scaling_ratio": 3.454,

View file

@ -627,7 +627,7 @@ const HEAP_BUDGETED = [
* their hooks declare three or four parameters — so their numbers stay exactly
* where they were.
*/
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python'];
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift'];
/**
* Needs `node --expose-gc` to force collection for a clean delta; without it
@ -1869,7 +1869,7 @@ function resolveOne(lang, from, target, pass) {
if (lang === 'swift') {
return resolveSwiftImportTarget(
{ kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target },
{ fromFile: from, allFilePaths },
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
);
}
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
@ -2326,6 +2326,26 @@ const CONTEXT_PROBE = {
probeFile('app/main.py', [['Function', 'app.main.run']]),
],
},
/**
* `import App` where App `@_exported import`s Models. With parsedFiles the
* re-export closure unions Models' files; without it the adapter returns
* only App's own file. Two distinct non-null answers, so a dropped
* `parsedFiles` cannot look like a miss.
*/
swift: {
from: 'Sources/Client/Main.swift',
target: 'App',
parsedFiles: [
{
...probeFile('Sources/App/Lib.swift', [['Class', 'App.Lib']]),
parsedImports: [
{ kind: 'reexport', localName: 'Models', importedName: 'Models', targetRaw: 'Models' },
],
},
probeFile('Sources/Models/User.swift', [['Class', 'Models.User']]),
probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]),
],
},
};
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
@ -3065,7 +3085,7 @@ expectNoOrphanKeys(
// against a claim in a comment. `run.ts` passes the fifth argument to every
// provider; which ones can OBSERVE it is decided by how many parameters each
// hook declares, and that is a number the registry can be asked for. Today
// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 —
// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 —
// which is why thirteen arms can ignore this whole question and their numbers
// did not move when it was fixed.
//

View file

@ -0,0 +1,52 @@
{
"_what": "Baselines for bench/swift-package-imports/measure.mjs --check. Guards Swift Package.swift declaration resolve after #2964 / #2931: declared-target hits, SDK and undeclared-folder misses, empty declaredTargets fail-closed, @_exported union, #2931 nested-prefix membership, first-wins grouping, and https:// factory survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.",
"_triage": "READ THIS BEFORE RE-RUNNING. targets, files, imports, declared_resolved, sdk_external, undeclared_external, empty_declared_external, reexport_extra, nested_repeat_resolved, first_wins, parse_targets, parse_binary_skipped, url_comment_targets, parse_complete and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. resolve_scaling_ratio, strategy_scaling_ratio and parse_scaling_ratio are the only timing arms; runner contention dominates them, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.",
"targets": 16,
"files": 260,
"imports": 12288,
"_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. declared_resolved only asserts something while the corpus still walks many files and imports. Shrink it to one happy-path module and declared_resolved still reads 32 and still passes, asserting a property the corpus no longer has. Files-per-target is 16 and does not scale — 4x grows target count so a hit returns a constant-size file list. Scaling files-per-target instead makes the return-list copy look quadratic even when the index is linear (the same trap bench/import-target documents for Swift collide_scaling).",
"declared_resolved": 32,
"sdk_external": 32,
"undeclared_external": 16,
"empty_declared_external": 1,
"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.",
"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.",
"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.",
"resolve_scaling_budget": 1.9,
"strategy_scaling_budget": 1.6,
"parse_scaling_budget": 1.9,
"_resolve_scaling_note": "(t_4n / t_n) / 4 for resolveSwiftImportTarget over the declared corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. A per-import allFilePaths scan scores ~4. Budget is 1.9, i.e. 1.51x the measured maximum 1.261 — this file's siblings use ~1.5x on ratios. min-of-15 estimator.",
"_strategy_scaling_note": "(t_4n / t_n) / 4 for swiftPackageStrategy (the import-config WeakMap index). Measured maximum 1.030; budget 1.6 is 1.55x, matching parse-dispatch-rounds' pack_scaling_budget.",
"_parse_scaling_note": "(t_4n / t_n) / 4 for parseSwiftPackageManifest over 256 vs 1024 factories. Measured maximum 1.276; budget 1.9 is 1.49x. The small cell is still sub-millisecond, so this budget is looser than strategy on purpose.",
"_measured": {
"resolve_scaling_ratio": 1.261,
"resolve_scaling_ratio_samples": [1.261, 1.201, 1.182, 1.241, 1.212],
"strategy_scaling_ratio": 1.03,
"strategy_scaling_ratio_samples": [1.019, 0.994, 0.97, 1.015, 1.03],
"parse_scaling_ratio": 1.276,
"parse_scaling_ratio_samples": [1.25, 1.276, 1.215, 1.224, 1.227],
"resolve_ms": 3.5,
"resolve_ms_4x": 17.16,
"strategy_ms": 0.67,
"strategy_ms_4x": 2.65,
"parse_ms": 0.41,
"parse_ms_4x": 2.01,
"reps": 15
},
"_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them."
}

View file

@ -0,0 +1,475 @@
/**
* Build-free bench for Swift Package.swift declaration resolve (#2964 / #2931).
*
* WHY THIS EXISTS. `bench/import-target`'s Swift arm never threads a
* `resolutionConfig`. It times the no-manifest directory-segment index
* (`getSwiftModuleIndex`) and cannot see a revert that starts scanning
* `allFilePaths` per `import`, drops declaration-only resolve, treats
* `https://` as a comment, or matches a target dir inside another path
* segment (#2931). Graph output on a tiny fixture is identical either way.
* This file is the same shape as `bench/parse-dispatch-rounds`: exact floors
* first, ratio timing only, never a millisecond ceiling.
*
* ARMS:
*
* - `targets` / `files` / `imports` — EXACT, and they are the FLOOR.
* `declared_resolved` only asserts something while the corpus still has
* many files and imports to walk. Shrink it to one happy-path module and
* the resolve arm still passes, gating a property the corpus no longer has.
*
* - `declared_resolved` / `sdk_external` / `undeclared_external` /
* `empty_declared_external` / `reexport_extra` / `nested_repeat_resolved` /
* `first_wins` — EXACT. Declaration map hits stay hits. Foundation / UIKit
* 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.
*
* - `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`.
*
* - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files`
* rows on the unique query set. Catches a target-set change that leaves
* the counts intact.
*
* - `resolve_scaling_ratio` / `strategy_scaling_ratio` / `parse_scaling_ratio`
* — the only timing arms, RATIOS not millisecond ceilings.
* `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear.
* Files-per-target is FIXED while target count (and therefore files and
* imports) grow 4x, so a hit returns a constant-size file list. A correct
* once-per-pass index is then linear in imports; a per-import scan of
* allFilePaths scores ~4. Parse scales factory count 4x on a fixed-shape
* manifest.
*
* Usage:
* node --import tsx bench/swift-package-imports/measure.mjs
* node --import tsx bench/swift-package-imports/measure.mjs --check
*/
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
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';
const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8'));
const REPS = 15;
const TARGETS_SMALL = 16;
const FILES_PER_TARGET = 16;
const QUERY_REPEATS = 8;
const PARSE_TARGETS = 256;
const SCALE = 4;
function queryKinds(targetCount) {
return [
(t) => ({ raw: `Mod${(t + 1) % targetCount}`, expect: 'declared' }),
() => ({ raw: 'Foundation', expect: 'sdk' }),
() => ({ raw: 'CoreUI', expect: 'undeclared' }),
(t) => ({ raw: `Mod${(t + 1) % targetCount}.Model`, expect: 'declared' }),
() => ({ raw: 'UIKit', expect: 'sdk' }),
() => ({ raw: 'Ghost', expect: 'miss' }),
];
}
function parsedImport(targetRaw) {
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function stubFile(filePath, parsedImports = []) {
return {
filePath,
moduleScope: `module:${filePath}`,
scopes: [],
parsedImports,
localDefs: [],
referenceSites: [],
};
}
function targetMap(targetCount) {
const targets = new Map();
for (let t = 0; t < targetCount; t++) targets.set(`Mod${t}`, `Sources/Mod${t}`);
return targets;
}
function declaredConfig(targets) {
return { origin: 'package.swift', targets, declaredTargets: targets };
}
function buildCorpus(targetCount) {
const targets = targetMap(targetCount);
const files = [];
const parsedFiles = [];
for (let t = 0; t < targetCount; t++) {
for (let i = 0; i < FILES_PER_TARGET; i++) {
const filePath = `Sources/Mod${t}/File${i}.swift`;
files.push(filePath);
const imports =
t === 0 && i === 0
? [{ kind: 'reexport', localName: 'Mod1', importedName: 'Mod1', targetRaw: 'Mod1' }]
: [];
parsedFiles.push(stubFile(filePath, imports));
}
}
const foundation = 'Sources/Foundation/Thing.swift';
const coreUi = 'Sources/CoreUI/View.swift';
const nested = 'vendor/Sources/Mod0/Sources/Mod0/Nested.swift';
const clash = 'Sources/Mod0/Vendor/Sources/Mod1/Clash.swift';
for (const extra of [foundation, coreUi, nested, clash]) {
files.push(extra);
parsedFiles.push(stubFile(extra));
}
const kinds = queryKinds(targetCount);
const queries = [];
for (let t = 0; t < targetCount; t++) {
for (let i = 0; i < FILES_PER_TARGET; i++) {
const from = `Sources/Mod${t}/File${i}.swift`;
for (let r = 0; r < QUERY_REPEATS; r++) {
for (const kind of kinds) {
const { raw, expect } = kind(t);
queries.push({ from, raw, expect, unique: r === 0 && i === 0 });
}
}
}
}
return {
files,
parsedFiles,
queries,
targets,
config: declaredConfig(targets),
extras: { foundation, coreUi, nested, clash },
targetCount,
};
}
function outcomeKey(from, raw, result) {
if (result == null) return `${from}|${raw}-><null>`;
const list = typeof result === 'string' ? [result] : [...result];
return `${from}|${raw}->${list.sort().join(',')}`;
}
function resultFiles(result) {
if (result == null) return [];
return typeof result === 'string' ? [result] : [...result];
}
function resolveOne(query, allFilePaths, config, parsedFiles) {
return resolveSwiftImportTarget(parsedImport(query.raw), {
fromFile: query.from,
allFilePaths,
resolutionConfig: config,
parsedFiles,
});
}
function strategyCtx(files, config) {
return {
allFilePaths: new Set(files),
allFileList: files,
normalizedFileList: files.map((p) => p.replace(/\\/g, '/')),
index: {},
resolveCache: new Map(),
configs: {
tsconfigPaths: null,
goModule: null,
composerConfig: null,
swiftPackageConfig: config,
csharpConfigs: [],
},
};
}
function resolvePass(corpus) {
const allFilePaths = new Set(corpus.files);
let resolved = 0;
for (const query of corpus.queries) {
if (resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles) != null) resolved++;
}
return resolved;
}
function strategyPass(corpus) {
const ctx = strategyCtx(corpus.files, corpus.config);
let resolved = 0;
for (const query of corpus.queries) {
if (swiftPackageStrategy(query.raw, query.from, ctx) != null) resolved++;
}
return resolved;
}
function manifestFor(targetCount) {
const rows = [];
for (let i = 0; i < targetCount; i++) {
rows.push(
` .target(name: "Mod${i}", dependencies: [.product(name: "X", package: "https://example.com/x")]),`,
);
if (i % 8 === 0) {
rows.push(
` .binaryTarget(name: "Bin${i}", url: "https://example.com/b${i}.xcframework"),`,
);
}
}
return `let package = Package(\n name: "Demo",\n targets: [\n${rows.join('\n')}\n ]\n)\n`;
}
function fastest(fn, reps) {
fn();
let best = Infinity;
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
fn();
best = Math.min(best, performance.now() - t0);
}
return best;
}
function correctness(corpus) {
const allFilePaths = new Set(corpus.files);
const records = [];
let declaredResolved = 0;
let sdkExternal = 0;
let undeclaredExternal = 0;
const unique = corpus.queries.filter((q) => q.unique);
for (const query of unique) {
const result = resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles);
records.push(outcomeKey(query.from, query.raw, result));
if (query.expect === 'declared' && result != null) declaredResolved++;
if (query.expect === 'sdk' && result == null) sdkExternal++;
if (query.expect === 'undeclared' && result == null) undeclaredExternal++;
}
const fromOther = 'Sources/Mod2/File0.swift';
const reexport = resultFiles(
resolveOne({ from: fromOther, raw: 'Mod0' }, allFilePaths, corpus.config, corpus.parsedFiles),
);
const reexportExtra =
reexport.includes('Sources/Mod0/File0.swift') && reexport.includes('Sources/Mod1/File0.swift')
? 1
: 0;
const nestedRepeatResolved = reexport.includes(corpus.extras.nested) ? 1 : 0;
const empty = resolveOne(
{ from: fromOther, raw: 'Mod0' },
allFilePaths,
{ origin: 'package.swift', targets: corpus.targets, declaredTargets: new Map() },
corpus.parsedFiles,
);
const emptyDeclaredExternal = empty == null ? 1 : 0;
const groups = groupSwiftFilesBySpmTarget(corpus.files, (p) => p, corpus.targets);
const firstWins =
groups.get('Mod0')?.includes(corpus.extras.clash) === true &&
groups.get('Mod1')?.includes(corpus.extras.clash) !== true
? 1
: 0;
const parseFixed = parseSwiftPackageManifest(`
let package = Package(
name: "Demo",
dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")],
targets: [
.target(name: "Models"),
.target(name: "App"),
.binaryTarget(name: "Lib", url: "https://example.com/Lib.xcframework", checksum: "abc"),
.testTarget(name: "AppTests"),
.plugin(name: "Gen"),
.systemLibrary(name: "CFoo"),
]
)
`);
const urlComment = parseSwiftPackageManifest(
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
);
return {
declaredResolved,
sdkExternal,
undeclaredExternal,
emptyDeclaredExternal,
reexportExtra,
nestedRepeatResolved,
firstWins,
parseTargets: parseFixed.targets.size,
parseBinarySkipped:
parseFixed.targets.has('Lib') ||
parseFixed.targets.has('Gen') ||
parseFixed.targets.has('CFoo')
? 0
: 1,
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'),
};
}
const small = buildCorpus(TARGETS_SMALL);
const large = buildCorpus(TARGETS_SMALL * SCALE);
const shape = correctness(small);
const smallResolveMs = fastest(() => resolvePass(small), REPS);
const largeResolveMs = fastest(() => resolvePass(large), REPS);
const resolveScaling = largeResolveMs / smallResolveMs / SCALE;
const smallStrategyMs = fastest(() => strategyPass(small), REPS);
const largeStrategyMs = fastest(() => strategyPass(large), REPS);
const strategyScaling = largeStrategyMs / smallStrategyMs / SCALE;
const parseSmallSrc = manifestFor(PARSE_TARGETS);
const parseLargeSrc = manifestFor(PARSE_TARGETS * SCALE);
const parseSmall = parseSwiftPackageManifest(parseSmallSrc);
const parseLarge = parseSwiftPackageManifest(parseLargeSrc);
const smallParseMs = fastest(() => parseSwiftPackageManifest(parseSmallSrc), REPS);
const largeParseMs = fastest(() => parseSwiftPackageManifest(parseLargeSrc), REPS);
const parseScaling = largeParseMs / smallParseMs / SCALE;
const files = small.files.length;
const imports = small.queries.length;
console.log(`targets : ${small.targetCount} (expect ${baselines.targets})`);
console.log(`files : ${files} (expect ${baselines.files})`);
console.log(`imports : ${imports} (expect ${baselines.imports})`);
console.log(
`declared_resolved : ${shape.declaredResolved} (expect ${baselines.declared_resolved})`,
);
console.log(`sdk_external : ${shape.sdkExternal} (expect ${baselines.sdk_external})`);
console.log(
`undeclared_external : ${shape.undeclaredExternal} (expect ${baselines.undeclared_external})`,
);
console.log(
`empty_declared_external: ${shape.emptyDeclaredExternal} (expect ${baselines.empty_declared_external})`,
);
console.log(
`reexport_extra : ${shape.reexportExtra} (expect ${baselines.reexport_extra})`,
);
console.log(
`nested_repeat_resolved : ${shape.nestedRepeatResolved} (expect ${baselines.nested_repeat_resolved})`,
);
console.log(`first_wins : ${shape.firstWins} (expect ${baselines.first_wins})`);
console.log(`parse_targets : ${shape.parseTargets} (expect ${baselines.parse_targets})`);
console.log(
`parse_binary_skipped : ${shape.parseBinarySkipped} (expect ${baselines.parse_binary_skipped})`,
);
console.log(
`url_comment_targets : ${shape.urlCommentTargets} (expect ${baselines.url_comment_targets})`,
);
console.log(
`parse_complete : ${shape.parseComplete} (expect ${baselines.parse_complete})`,
);
console.log(`layout_fingerprint : ${shape.fingerprint}`);
console.log(
`resolve_scaling_ratio : ${resolveScaling.toFixed(3)} (budget <= ${baselines.resolve_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`strategy_scaling_ratio : ${strategyScaling.toFixed(3)} (budget <= ${baselines.strategy_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`parse_scaling_ratio : ${parseScaling.toFixed(3)} (budget <= ${baselines.parse_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`reps : ${REPS} resolve ${smallResolveMs.toFixed(2)}ms / 4x ${largeResolveMs.toFixed(2)}ms strategy ${smallStrategyMs.toFixed(2)}ms / 4x ${largeStrategyMs.toFixed(2)}ms parse ${smallParseMs.toFixed(2)}ms / 4x ${largeParseMs.toFixed(2)}ms`,
);
console.log(
`parse_scale_shape : ${parseSmall.targets.size} -> ${parseLarge.targets.size} targets (expect ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE})`,
);
if (process.argv.includes('--check')) {
let failed = false;
if (shape.fingerprint !== baselines.layout_fingerprint) {
failed = true;
console.error(
`\nFAIL layout_fingerprint: ${shape.fingerprint}\n` +
` expected ${baselines.layout_fingerprint}\n` +
` Unique-query target set moved. Explain it; do not re-baseline alone.`,
);
}
const exact = [
['targets', small.targetCount],
['files', files],
['imports', imports],
['declared_resolved', shape.declaredResolved],
['sdk_external', shape.sdkExternal],
['undeclared_external', shape.undeclaredExternal],
['empty_declared_external', shape.emptyDeclaredExternal],
['reexport_extra', shape.reexportExtra],
['nested_repeat_resolved', shape.nestedRepeatResolved],
['first_wins', shape.firstWins],
['parse_targets', shape.parseTargets],
['parse_binary_skipped', shape.parseBinarySkipped],
['url_comment_targets', shape.urlCommentTargets],
['parse_complete', shape.parseComplete],
];
for (const [name, value] of exact) {
if (value !== baselines[name]) {
failed = true;
console.error(`\nFAIL ${name}: ${value}, expected exactly ${baselines[name]}.`);
}
}
if (
small.targetCount !== baselines.targets ||
files !== baselines.files ||
imports !== baselines.imports
) {
failed = true;
console.error(
`\nFAIL shape: the corpus must stay large enough that declared_resolved still measures a walk.`,
);
}
if (
parseSmall.targets.size !== PARSE_TARGETS ||
parseLarge.targets.size !== PARSE_TARGETS * SCALE
) {
failed = true;
console.error(
`\nFAIL parse scale shape: ${parseSmall.targets.size} -> ${parseLarge.targets.size}, ` +
`expected ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE}.`,
);
}
if (resolveScaling > baselines.resolve_scaling_budget) {
failed = true;
console.error(
`\nFAIL resolve_scaling_ratio: ${resolveScaling.toFixed(3)} exceeds ` +
`${baselines.resolve_scaling_budget} (~1.0 is linear).\n` +
` resolveSwiftImportTarget grew superlinearly in file+import count — a\n` +
` per-import scan of allFilePaths scores ~4 here. Re-run on an idle\n` +
` machine before investigating, and check \`reps\` first.`,
);
}
if (strategyScaling > baselines.strategy_scaling_budget) {
failed = true;
console.error(
`\nFAIL strategy_scaling_ratio: ${strategyScaling.toFixed(3)} exceeds ` +
`${baselines.strategy_scaling_budget} (~1.0 is linear).\n` +
` swiftPackageStrategy grew superlinearly — usually the WeakMap target\n` +
` index falling back to O(imports × files). Re-run idle; check \`reps\`.`,
);
}
if (parseScaling > baselines.parse_scaling_budget) {
failed = true;
console.error(
`\nFAIL parse_scaling_ratio: ${parseScaling.toFixed(3)} exceeds ` +
`${baselines.parse_scaling_budget} (~1.0 is linear).\n` +
` parseSwiftPackageManifest grew superlinearly in factory count.\n` +
` Re-run on an idle machine before investigating, and check \`reps\` first.`,
);
}
if (failed) process.exit(1);
console.log('\nOK — within budget.');
}

View file

@ -13,18 +13,22 @@
* every strategy invocation, per `import-processor`'s build-once
* context). Lookup per import is then O(1).
*
* Behavior is preserved bit-for-bit: a file is attributed to a target
* iff its **forward-slash (backslash-normalized), case-sensitive** path
* starts with `<targetDir>/`, matching the old
* `normalizedFileList[i].startsWith(targetDir + '/')` comparison
* (`normalizedFileList` is only backslash→forward-slash normalized — NOT
* lowercased — so the match is case-sensitive); the returned paths are
* the original-case `allFileList` entries; and the per-target file ORDER
* follows `allFileList`, so the emitted `{ kind: 'files', files }` set and
* ordering are identical to the old scan.
* A file is attributed to a target iff its **forward-slash
* (backslash-normalized), case-sensitive** path matches at a
* **segment boundary**: it starts with `<targetDir>/` or contains
* `/<targetDir>/`. `normalizedFileList` is
* only backslash→forward-slash normalized — NOT lowercased — so the
* match is case-sensitive. The returned paths are the original-case
* `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 { SupportedLanguages } from 'gitnexus-shared';
import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js';
import type { ImportResolutionConfig, ImportResolverStrategy, ResolveCtx } from '../types.js';
interface SwiftTargetIndex {
@ -66,10 +70,10 @@ function getSwiftTargetIndex(
// Pre-compute each target's directory prefix once (original case, to
// match the legacy comparison against the forward-slash-normalized,
// case-sensitive file list — see module docstring).
const targetPrefixes: { name: string; prefix: string }[] = [];
const targetDirs: { name: string; prefix: string }[] = [];
const byTarget = new Map<string, string[]>();
for (const [name, dir] of targets) {
targetPrefixes.push({ name, prefix: dir + '/' });
targetDirs.push({ name, prefix: swiftDeclaredTargetPrefix(dir) });
byTarget.set(name, []);
}
@ -82,10 +86,10 @@ function getSwiftTargetIndex(
for (let i = 0; i < ctx.allFileList.length; i++) {
const norm = ctx.normalizedFileList[i];
if (!norm.endsWith('.swift')) continue;
for (const { name, prefix } of targetPrefixes) {
if (norm.startsWith(prefix)) {
byTarget.get(name)!.push(ctx.allFileList[i]);
}
for (const { name, prefix } of targetDirs) {
if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue;
const bucket = byTarget.get(name);
if (bucket !== undefined) bucket.push(ctx.allFileList[i]);
}
}
@ -97,19 +101,19 @@ function getSwiftTargetIndex(
/** Swift Package.swift target map resolution strategy. */
export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
if (swiftPackageConfig) {
// Only the targets map is needed; build the index lazily so repos
// without a Package.swift config pay nothing.
if (swiftPackageConfig.targets.has(rawImportPath)) {
const index = getSwiftTargetIndex(ctx, swiftPackageConfig.targets);
const files = index.byTarget.get(rawImportPath);
if (files !== undefined && files.length > 0) {
// Copy so callers can't mutate the cached index bucket.
return { kind: 'files', files: [...files] };
}
}
if (swiftPackageConfig == null) return null;
const declared = coerceDeclaredSwiftTargets(swiftPackageConfig);
if (declared == null) return null;
const moduleName = rawImportPath.split('.')[0];
if (moduleName === '' || !declared.has(moduleName)) {
return null;
}
return null; // External framework (Foundation, UIKit, etc.)
const index = getSwiftTargetIndex(ctx, declared);
const files = index.byTarget.get(moduleName);
if (files !== undefined && files.length > 0) {
return { kind: 'files', files: [...files] };
}
return null;
};
export const swiftImportConfig: ImportResolutionConfig = {

View file

@ -175,9 +175,55 @@ export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEv
}
/** Swift Package Manager module config */
export type SwiftPackageConfigOrigin = 'package.swift' | 'directories';
export interface SwiftPackageConfig {
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
targets: Map<string, string>;
/**
* `package.swift` — extracted from a readable Package.swift with no
* completeness hazards. Explicit import resolve may treat this as a
* declaration map (empty means every name is external).
* `directories` — inferred from `Sources/*` (or Package/Sources / src)
* when no usable declaration exists. Grouping uses this; import resolve
* must not.
* Omitted on hand-built test configs: treated as a declaration map so
* existing `{ targets }` fixtures stay valid.
*/
origin?: SwiftPackageConfigOrigin;
/**
* Declaration map when `origin` is `package.swift` (may be empty).
* Grouping uses `targets`, which is this map when it is non-empty and the
* inferred `Sources/*` map when the declaration is empty — so a
* binary-only Package.swift does not collapse every file into `__default__`.
*/
declaredTargets?: Map<string, string>;
}
/**
* Declaration view for explicit import resolve. `origin: 'directories'`
* is grouping-only. A hand-built `{ targets }` with no origin stays a
* declaration so existing fixtures keep working.
*/
export function coerceDeclaredSwiftTargets(
resolutionConfig: unknown,
): ReadonlyMap<string, string> | null {
const config = resolutionConfig as Partial<SwiftPackageConfig> | null | undefined;
if (config == null) return null;
if (config.origin === 'directories') return null;
if (config.declaredTargets instanceof Map) return config.declaredTargets;
if (config.targets instanceof Map) return config.targets;
return null;
}
/** Segment-boundary prefix for a Package.swift `path:`. `"."` / `"./"` is the package root. */
export function swiftDeclaredTargetPrefix(dir: string): string {
let norm = dir.replace(/\\/g, '/');
while (norm.startsWith('./')) {
norm = norm.slice(2);
}
norm = norm.replace(/\/+$/, '');
return norm === '' || norm === '.' ? '' : `${norm}/`;
}
/** Zig package config parsed from build.zig.zon and the root build.zig */
@ -616,12 +662,768 @@ async function collectDeclaredNamespaces(
return structure.incomplete ? 'truncated' : 'ok';
}
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
// Swift imports are module-name based (e.g., `import SiuperModel`)
// SPM convention: Sources/<TargetName>/ or Package/Sources/<TargetName>/
// We scan for these directories to build a target map
const targets = new Map<string, string>();
const SWIFT_SOURCE_FACTORY_NAMES = ['target', 'executableTarget', 'testTarget', 'macro'] as const;
const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'plugin', '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*\\(`,
'g',
);
function extractBalancedParen(source: string, openIndex: number): string | null {
let depth = 0;
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = openIndex; i < source.length; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
// `https://` lives inside a string, already excluded above.
inLineComment = true;
i++;
continue;
} else if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return source.slice(openIndex + 1, i);
}
}
return null;
}
function isSwiftIdentCont(ch: string | undefined): boolean {
return ch !== undefined && /[A-Za-z0-9_]/.test(ch);
}
function skipSwiftWsAndComments(source: string, start: number): number | null {
let i = start;
while (i < source.length) {
const ch = source[i];
const next = source[i + 1];
if (/\s/.test(ch)) {
i++;
continue;
}
if (ch === '/' && next === '/') {
const nl = source.indexOf('\n', i + 2);
if (nl === -1) return null;
i = nl + 1;
continue;
}
if (ch === '/' && next === '*') {
let depth = 1;
i += 2;
while (i < source.length && depth > 0) {
if (source[i] === '/' && source[i + 1] === '*') {
depth++;
i += 2;
} else if (source[i] === '*' && source[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
if (depth !== 0) return null;
continue;
}
return i;
}
return null;
}
/** First `name:` / `path:` string outside comments. Escapes and interpolations are unreadable. */
function readSwiftFactoryField(
block: string,
field: 'name' | 'path',
): { value: string | undefined; keyPresent: boolean } {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = 0; i < block.length; i++) {
const ch = block[i];
const next = block[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (!/[A-Za-z_]/.test(ch)) continue;
let j = i + 1;
while (j < block.length && isSwiftIdentCont(block[j])) j++;
if (block.slice(i, j) !== field) {
i = j - 1;
continue;
}
const colonAt = skipSwiftWsAndComments(block, j);
if (colonAt === null || block[colonAt] !== ':') {
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 { value: undefined, keyPresent: false };
}
/** Quoted literal with no escapes. Any `\` (including `\u{…}` and `\(`) is unreadable. */
function readSwiftSimpleQuotedString(source: string, openIndex: number): string | null {
const quote = source[openIndex];
let i = openIndex + 1;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') return null;
if (ch === quote) return source.slice(openIndex + 1, i);
if (ch === '\n') return null;
i++;
}
return null;
}
function swiftManifestHasCompletenessHazard(source: string): boolean {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
let atLineStart = true;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') {
inLineComment = false;
atLineStart = true;
}
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
} else if (ch === '\n') {
atLineStart = true;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
else if (ch === '\n') atLineStart = true;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
atLineStart = false;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
atLineStart = false;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
atLineStart = false;
continue;
}
if (ch === '\n') {
atLineStart = true;
continue;
}
if (atLineStart && /\s/.test(ch)) continue;
if (atLineStart && ch === '#') {
if (source.startsWith('if', i + 1) && !isSwiftIdentCont(source[i + 3])) return true;
if (source.startsWith('elseif', i + 1) && !isSwiftIdentCont(source[i + 7])) return true;
}
atLineStart = false;
}
return false;
}
interface SwiftCommentScan {
i: number;
inString: '"' | "'" | null;
escape: boolean;
inLineComment: boolean;
blockCommentDepth: number;
}
function newSwiftCommentScan(): SwiftCommentScan {
return { i: 0, inString: null, escape: false, inLineComment: false, blockCommentDepth: 0 };
}
/** Resume the comment/string walk up to `upTo`. Matches are left-to-right, so this is O(n) over the file. */
function advanceSwiftCommentScan(source: string, state: SwiftCommentScan, upTo: number): void {
let { i, inString, escape, inLineComment, blockCommentDepth } = state;
for (; i < upTo; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
}
}
state.i = i;
state.inString = inString;
state.escape = escape;
state.inLineComment = inLineComment;
state.blockCommentDepth = blockCommentDepth;
}
function swiftPathIsUnreadable(customPath: string | undefined, hasPathKey: boolean): boolean {
if (!hasPathKey) return false;
return customPath === undefined || customPath === '' || customPath.includes('\\(');
}
/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */
export function parseSwiftPackageManifest(source: string): {
targets: Map<string, string>;
complete: boolean;
} {
const targets = new Map<string, string>();
if (swiftManifestHasCompletenessHazard(source)) {
return { targets, complete: false };
}
const packageTargets = inspectSwiftPackageTargets(source);
SWIFT_FACTORY_RE.lastIndex = 0;
let match: RegExpExecArray | null;
let sawUnreadableFactory = false;
const commentScan = newSwiftCommentScan();
let coveredEnd = -1;
while ((match = SWIFT_FACTORY_RE.exec(source)) !== null) {
advanceSwiftCommentScan(source, commentScan, match.index);
if (
commentScan.inLineComment ||
commentScan.blockCommentDepth > 0 ||
commentScan.inString !== null
) {
continue;
}
if (
packageTargets.sawPackage &&
!packageTargets.arraySpans.some(([lo, hi]) => match.index >= lo && match.index <= hi)
) {
continue;
}
if (match.index > 0 && match.index < coveredEnd) continue;
const kind = match[1];
const paren = source.indexOf('(', match.index);
const block = extractBalancedParen(source, paren);
if (block === null) {
sawUnreadableFactory = true;
continue;
}
coveredEnd = Math.max(coveredEnd, paren + 1 + block.length + 1);
if (SWIFT_SKIP_FACTORIES.has(kind)) continue;
const nameField = readSwiftFactoryField(block, 'name');
if (nameField.value === undefined || nameField.value === '') {
sawUnreadableFactory = true;
continue;
}
const name = nameField.value;
const pathField = readSwiftFactoryField(block, 'path');
const customPath = pathField.value;
if (swiftPathIsUnreadable(customPath, pathField.keyPresent)) {
sawUnreadableFactory = true;
continue;
}
const dir = customPath ?? (kind === 'testTarget' ? `Tests/${name}` : `Sources/${name}`);
const existing = targets.get(name);
if (existing === undefined) {
targets.set(name, dir);
} else if (customPath !== undefined && existing === `Sources/${name}`) {
// A later `.target(name:path:)` wins over an earlier same-name
// factory that only implied the default path.
targets.set(name, customPath);
}
}
return {
targets,
complete: !sawUnreadableFactory && !packageTargets.helperBuilt,
};
}
interface SwiftPackageTargetsInspection {
helperBuilt: boolean;
sawPackage: boolean;
arraySpans: Array<[number, number]>;
}
const SWIFT_ALL_FACTORY_NAMES = new Set<string>([
...SWIFT_SOURCE_FACTORY_NAMES,
...SWIFT_SKIP_FACTORY_NAMES,
]);
/** Locate `Package(...)`'s `targets:` argument. Product `targets:` stay nested. */
function inspectSwiftPackageTargets(source: string): SwiftPackageTargetsInspection {
const arraySpans: Array<[number, number]> = [];
let helperBuilt = false;
const seen = { package: false };
const unreadable = forEachSwiftPackageArgs(
source,
(args, argsStart) => {
const found = inspectPackageTargetsArg(args);
if (found.helperBuilt) {
helperBuilt = true;
return true;
}
if (found.arrayStart !== null && found.arrayEnd !== null) {
arraySpans.push([argsStart + found.arrayStart, argsStart + found.arrayEnd]);
}
return false;
},
seen,
);
return { helperBuilt: helperBuilt || unreadable, sawPackage: seen.package, arraySpans };
}
/** Walk `Package(` calls outside comments/strings. Unclosed `Package(` is incomplete. */
function forEachSwiftPackageArgs(
source: string,
visit: (args: string, argsStart: number) => boolean,
seen: { package: boolean },
): boolean {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (
!source.startsWith('Package', i) ||
isSwiftIdentCont(source[i + 7]) ||
(i > 0 && isSwiftIdentCont(source[i - 1]))
) {
continue;
}
const parenAt = skipSwiftWsAndComments(source, i + 7);
if (parenAt === null || source[parenAt] !== '(') continue;
seen.package = true;
const args = extractBalancedParen(source, parenAt);
if (args === null) return true;
if (visit(args, parenAt + 1)) return true;
i = parenAt + args.length + 1;
}
return false;
}
function inspectPackageTargetsArg(args: string): {
helperBuilt: boolean;
arrayStart: number | null;
arrayEnd: number | null;
} {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
let paren = 0;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
const next = args[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (ch === '(') {
paren++;
continue;
}
if (ch === ')') {
paren--;
continue;
}
if (paren !== 0) continue;
if (
!args.startsWith('targets', i) ||
isSwiftIdentCont(args[i + 7]) ||
(i > 0 && isSwiftIdentCont(args[i - 1]))
) {
continue;
}
const colonAt = skipSwiftWsAndComments(args, i + 7);
if (colonAt === null || args[colonAt] !== ':') {
i += 6;
continue;
}
return classifyPackageTargetsValue(args, colonAt + 1);
}
return { helperBuilt: false, arrayStart: null, arrayEnd: null };
}
function classifyPackageTargetsValue(
args: string,
afterColon: number,
): {
helperBuilt: boolean;
arrayStart: number | null;
arrayEnd: number | null;
} {
const start = skipSwiftWsAndComments(args, afterColon);
if (start === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null };
if (args[start] === '[') {
const close = matchSwiftSquare(args, start);
if (close === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null };
const next = skipSwiftWsAndComments(args, close + 1);
if (next !== null && args[next] === '+') {
return { helperBuilt: true, arrayStart: start, arrayEnd: close };
}
if (packageTargetsArrayHasComputed(args, start, close)) {
return { helperBuilt: true, arrayStart: start, arrayEnd: close };
}
return { helperBuilt: false, arrayStart: start, arrayEnd: close };
}
return { helperBuilt: true, arrayStart: null, arrayEnd: null };
}
function packageTargetsArrayHasComputed(source: string, open: number, close: number): boolean {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
let paren = 0;
let bracket = 0;
for (let i = open; i < close; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (ch === '[') {
bracket++;
continue;
}
if (ch === ']') {
bracket--;
continue;
}
if (ch === '(') {
paren++;
continue;
}
if (ch === ')') {
paren--;
continue;
}
if (bracket !== 1 || paren !== 0) continue;
if (ch === ',' || /\s/.test(ch)) continue;
if (ch === '.') {
let j = i + 1;
while (j < close && isSwiftIdentCont(source[j])) j++;
const name = source.slice(i + 1, j);
const after = skipSwiftWsAndComments(source, j);
if (after !== null && source[after] === '(' && SWIFT_ALL_FACTORY_NAMES.has(name)) {
const block = extractBalancedParen(source, after);
if (block === null) return true;
i = after + block.length + 1;
continue;
}
return true;
}
return true;
}
return false;
}
function matchSwiftSquare(source: string, openIndex: number): number | null {
let depth = 0;
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = openIndex; i < source.length; i++) {
const ch = source[i];
const next = source[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
} else if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) return i;
}
}
return null;
}
async function inferSwiftDirectoryTargets(repoRoot: string): Promise<Map<string, string>> {
const targets = new Map<string, string>();
const sourceDirs = ['Sources', 'Package/Sources', 'src'];
for (const sourceDir of sourceDirs) {
try {
@ -636,12 +1438,41 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
// Directory doesn't exist
}
}
return targets;
}
if (targets.size > 0) {
if (isDev) {
logger.info(`📦 Loaded ${targets.size} Swift package targets`);
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
try {
const source = await fs.readFile(path.join(repoRoot, 'Package.swift'), 'utf-8');
const parsed = parseSwiftPackageManifest(source);
if (parsed.complete) {
if (isDev) {
logger.info(`📦 Loaded ${parsed.targets.size} Swift package targets from Package.swift`);
}
if (parsed.targets.size > 0) {
return {
targets: parsed.targets,
origin: 'package.swift',
declaredTargets: parsed.targets,
};
}
const inferred = await inferSwiftDirectoryTargets(repoRoot);
return {
targets: inferred,
origin: 'package.swift',
declaredTargets: parsed.targets,
};
}
return { targets };
} catch {
// Missing or unreadable — fall through to inferred folders.
}
const inferred = await inferSwiftDirectoryTargets(repoRoot);
if (inferred.size > 0) {
if (isDev) {
logger.info(`📦 Inferred ${inferred.size} Swift source folders`);
}
return { targets: inferred, origin: 'directories' };
}
return null;
}

View file

@ -14,12 +14,15 @@
* 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` — replicating legacy
* `groupSwiftFilesByTarget`. With no scanned source dir the map is null
* 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). Every pair of distinct `.swift` files in the same module
* gets a directed IMPORTS edge in both directions (whole-module
* visibility is symmetric).
* 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).
*
* Node identity + edge construction mirror the generic `emitImportEdges`
* convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)`
@ -41,8 +44,6 @@ export function emitSwiftImplicitImportEdges(
_nodeLookup: GraphNodeLookup,
resolutionConfig?: unknown,
): void {
// Group files by SPM target subtree (the module). No-source-dir → all
// files in one `__default__` bucket.
const targets = coerceSwiftTargets(resolutionConfig);
const filesByTarget = groupSwiftFilesBySpmTarget(
parsedFiles,
@ -51,16 +52,15 @@ export function emitSwiftImplicitImportEdges(
);
for (const [, group] of filesByTarget) {
if (group.length < 2) continue; // no siblings to import
if (group.length < 2) continue;
for (const source of group) {
for (const target of group) {
if (source.filePath === target.filePath) continue; // no self-import
const dedupKey = `${source.filePath}->${target.filePath}`;
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', target.filePath),
targetId: generateId('File', dest.filePath),
type: 'IMPORTS',
confidence: 1.0,
reason: 'swift-scope: implicit module visibility',

View file

@ -4,32 +4,30 @@
* `@import.name` / `@import.testable` that `interpretSwiftImport`
* consumes.
*
* Swift imports are whole-module (no named members), so this is 1:1 —
* one `import` produces exactly one import. The split layer exposes the
* module name and the `@testable` flag without pushing raw-text parsing
* into `interpret.ts`.
* import Foundation → kind=namespace, source=Foundation
* import Foo.Bar → kind=namespace, source=Foo
* import struct Foo.Bar → kind=named, source=Foo, name=Bar
* @testable import MyApp → kind=namespace, source=MyApp, testable=1
* @_exported import Foo → kind=reexport, source=Foo, name=Foo
* @_exported import struct Foo.Bar → kind=reexport, source=Foo, name=Bar
*
* import Foundation → kind=namespace, source=Foundation
* import Foo.Bar → kind=namespace, source=Foo (SPM target),
* name=Foo.Bar (full path, for reference)
* @testable import MyApp → kind=namespace, source=MyApp, testable=1
*
* Verified against tree-sitter-swift 0.7.1:
* (import_declaration
* (modifiers (attribute (user_type (type_identifier))))? ; @testable / @_exported
* (identifier (simple_identifier)+)) ; one per dotted segment
* Import-kind (`struct`/`class`/…) is not a named tree-sitter child
* (hidden `_import_kind` in 0.7.1). Read it from the statement text.
* `@_exported` / `@testable` live on `modifiers`.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const IMPORT_KIND_TOKEN_RE = /^(struct|class|enum|protocol|func|let|var|typealias)\b/;
interface SwiftImportSpec {
/** SPM target name — the first dotted segment (`Foo` in `import Foo.Bar`). */
readonly source: string;
/** Full dotted module path (`Foo.Bar`). */
readonly memberName: string;
readonly fullPath: string;
/** True for `@testable import` (test-scope visibility; resolves identically). */
readonly testable: boolean;
readonly exported: boolean;
readonly importKind: string | null;
readonly atNode: SyntaxNode;
}
@ -42,14 +40,15 @@ export function splitSwiftImport(stmtNode: SyntaxNode): CaptureMatch | null {
function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null {
let testable = false;
let exported = false;
let identifierNode: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === null) continue;
if (child.type === 'modifiers') {
// Any attribute whose text mentions `testable` flips the flag.
if (/\btestable\b/.test(child.text)) testable = true;
if (swiftModifiersHaveAttribute(child.text, 'testable')) testable = true;
if (swiftModifiersHaveAttribute(child.text, '_exported')) exported = true;
} else if (child.type === 'identifier') {
identifierNode = child;
}
@ -57,36 +56,219 @@ function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null {
if (identifierNode === null) return null;
// The module path is one or more simple_identifier children, one per
// dotted segment. The SPM target is the FIRST segment.
const importKind = importKindFromClause(node, identifierNode);
const segments: string[] = [];
for (let i = 0; i < identifierNode.namedChildCount; i++) {
const seg = identifierNode.namedChild(i);
if (seg !== null && seg.type === 'simple_identifier') segments.push(seg.text);
}
if (segments.length === 0) {
// Fall back to the raw identifier text (e.g. a grammar shape we didn't
// anticipate). Split on `.` to recover the target segment.
const raw = identifierNode.text.trim();
if (raw === '') return null;
const parts = raw.split('.');
return { source: parts[0], fullPath: raw, testable, atNode: node };
segments.push(...raw.split('.'));
}
return {
source: segments[0],
memberName: segments.length > 1 ? segments[segments.length - 1] : segments[0],
fullPath: segments.join('.'),
testable,
exported,
importKind,
atNode: node,
};
}
function isSwiftIdentCont(ch: string | undefined): boolean {
return ch !== undefined && /[A-Za-z0-9_]/.test(ch);
}
/** First `word` outside comments and strings. Linear scan. */
function indexOfBareWord(text: string, word: string): number {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (
text.startsWith(word, i) &&
!isSwiftIdentCont(text[i + word.length]) &&
(i === 0 || !isSwiftIdentCont(text[i - 1]))
) {
return i;
}
}
return -1;
}
/** `@name` token in modifier text — not `_exported` / `testable` inside a string. */
function swiftModifiersHaveAttribute(text: string, name: 'testable' | '_exported'): boolean {
let inString: '"' | "'" | null = null;
let escape = false;
let inLineComment = false;
let blockCommentDepth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (blockCommentDepth > 0) {
if (ch === '*' && next === '/') {
blockCommentDepth--;
i++;
} else if (ch === '/' && next === '*') {
blockCommentDepth++;
i++;
}
continue;
}
if (inString !== null) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '/' && next === '/') {
inLineComment = true;
i++;
continue;
}
if (ch === '/' && next === '*') {
blockCommentDepth = 1;
i++;
continue;
}
if (ch !== '@') continue;
let j = i + 1;
while (j < text.length && /\s/.test(text[j])) j++;
if (text.startsWith(name, j) && !isSwiftIdentCont(text[j + name.length])) return true;
}
return false;
}
/** Kind token from the import clause only — skip `@available(..., message: "import struct")`. */
function importKindFromClause(node: SyntaxNode, identifierNode: SyntaxNode): string | null {
const identRel = identifierNode.startIndex - node.startIndex;
const before = identRel >= 0 ? node.text.slice(0, identRel) : node.text;
const importAt = indexOfBareWord(before, 'import');
const clause = importAt === -1 ? before : before.slice(importAt);
return kindAfterImportKeyword(clause);
}
/** After `import`, skip whitespace and comments, then read a kind token. Linear: no nested-quantifier backtracking. */
function kindAfterImportKeyword(clause: string): string | null {
const start = indexOfBareWord(clause, 'import');
if (start === -1) return null;
let i = start + 'import'.length;
let skipped = false;
while (i < clause.length) {
const ch = clause[i];
const next = clause[i + 1];
if (/\s/.test(ch)) {
skipped = true;
i += 1;
continue;
}
if (ch === '/' && next === '/') {
const nl = clause.indexOf('\n', i + 2);
if (nl === -1) return null;
skipped = true;
i = nl + 1;
continue;
}
if (ch === '/' && next === '*') {
let depth = 1;
i += 2;
while (i < clause.length && depth > 0) {
if (clause[i] === '/' && clause[i + 1] === '*') {
depth++;
i += 2;
} else if (clause[i] === '*' && clause[i + 1] === '/') {
depth--;
i += 2;
} else {
i++;
}
}
if (depth !== 0) return null;
skipped = true;
continue;
}
break;
}
if (!skipped) return null;
return IMPORT_KIND_TOKEN_RE.exec(clause.slice(i))?.[1] ?? null;
}
function bindingKind(spec: SwiftImportSpec): 'namespace' | 'named' | 'reexport' {
if (spec.exported) return 'reexport';
if (spec.importKind !== null && spec.fullPath.includes('.')) return 'named';
return 'namespace';
}
function buildImportMatch(stmtNode: SyntaxNode, spec: SwiftImportSpec): CaptureMatch {
const kind = bindingKind(spec);
const nameText = kind === 'namespace' ? spec.fullPath : spec.memberName;
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, 'namespace'),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.fullPath),
'@import.name': syntheticCapture('@import.name', spec.atNode, nameText),
};
if (spec.testable) {
m['@import.testable'] = syntheticCapture('@import.testable', spec.atNode, '1');

View file

@ -1,37 +1,27 @@
/**
* `resolveImportTarget` adapter for the Swift `ScopeResolver`.
*
* Swift's `import ModuleName` brings in a whole SPM target / framework
* module. The scope-resolution contract passes only `allFilePaths` (no
* `SwiftPackageConfig`), so we resolve a module name to the `.swift`
* files under a directory segment named after the module — the SPM
* convention `Sources/<Module>/*.swift` (and the common
* `<Module>/*.swift` layout). This needs no manifest parsing.
* 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 (intra-target) visibility — the bulk of Swift cross-file
* resolution, which needs NO `import` statement — is handled separately
* by `populateSwiftTargetSiblings` (see `target-siblings.ts`). This
* adapter only resolves EXPLICIT `import` statements (cross-module).
*
* Returns all matching files (one ImportEdge per file, like Go's
* package resolver) so every exported symbol in the module materializes
* a binding. Returns `null` for external frameworks (Foundation, UIKit,
* …) that have no in-repo directory.
*
* Performance: the directory→files grouping is memoized on the stable
* `allFilePaths` Set identity (the same Set is threaded to every import
* in a run), so it is built once per run — NOT once per import. Mirrors
* Python's `getPythonFileIndex` WeakMap pattern (PR #1918).
* Same-module visibility without `import` is `populateSwiftTargetSiblings`.
* This adapter only resolves EXPLICIT cross-module `import`s.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
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 { isSwiftSdkModule } from './sdk-modules.js';
export interface SwiftResolveContext {
readonly fromFile: string;
/** `ReadonlySet` so the orchestrator's stable run-level set flows
* straight through to the memoized index key. */
readonly allFilePaths: ReadonlySet<string>;
readonly resolutionConfig?: unknown;
readonly parsedFiles?: readonly ParsedFile[];
}
interface SwiftModuleIndex {
@ -40,16 +30,17 @@ interface SwiftModuleIndex {
readonly byModule: Map<string, string[]>;
}
interface SwiftDeclaredFileIndex {
readonly declared: ReadonlyMap<string, string>;
readonly byName: ReadonlyMap<string, string[]>;
}
const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet<string>): SwiftModuleIndex => {
const byModule = new Map<string, string[]>();
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
if (!norm.endsWith('.swift')) continue;
// Each interior directory segment is a candidate module name. A file
// `Sources/Models/User.swift` is attributed to module `Sources` and
// module `Models`; an `import Models` then resolves to it.
const segments = norm.split('/');
// Drop the filename (last segment); the rest are directory segments.
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
if (seg === '') continue;
@ -65,12 +56,70 @@ const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet<string>): Swif
return { byModule };
});
export function resolveSwiftImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | readonly string[] | null {
const SWIFT_DECLARED_INDEX = new WeakMap<ReadonlySet<string>, SwiftDeclaredFileIndex>();
function getDeclaredFilesByName(
allFilePaths: ReadonlySet<string>,
declared: ReadonlyMap<string, string>,
): ReadonlyMap<string, string[]> {
const hit = SWIFT_DECLARED_INDEX.get(allFilePaths);
if (hit !== undefined && hit.declared === declared) return hit.byName;
const dirs = [...declared.entries()].map(([name, dir]) => ({
name,
prefix: swiftDeclaredTargetPrefix(dir),
}));
const byName = new Map<string, string[]>();
for (const { name } of dirs) byName.set(name, []);
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
if (!norm.endsWith('.swift')) continue;
for (const { name, prefix } of dirs) {
if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue;
const bucket = byName.get(name);
if (bucket !== undefined) bucket.push(raw);
}
}
const index = { declared, byName };
SWIFT_DECLARED_INDEX.set(allFilePaths, index);
return byName;
}
const getSwiftReexportFlag = perFileSet(
(parsedFiles: readonly ParsedFile[]): { hasReexport: boolean } => {
for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
if (imp.kind === 'reexport') return { hasReexport: true };
}
}
return { hasReexport: false };
},
);
const getSwiftParsedByPath = perFileSet(
(parsedFiles: readonly ParsedFile[]): ReadonlyMap<string, ParsedFile> => {
const byPath = new Map<string, ParsedFile>();
for (const parsed of parsedFiles) {
byPath.set(parsed.filePath, parsed);
}
return byPath;
},
);
function excludeImporter(files: readonly string[], fromFile: string): string[] {
return files.filter((f) => f !== fromFile);
}
function firstSwiftModuleSegment(targetRaw: string): string | null {
if (targetRaw === '') return null;
const moduleName = targetRaw.split('.')[0];
return moduleName === '' ? null : moduleName;
}
function narrowContext(workspaceIndex: WorkspaceIndex): SwiftResolveContext | null {
const ctx = workspaceIndex as SwiftResolveContext | undefined;
// Duck-type the set (PR #1918 P2: don't `instanceof Set`).
const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths;
if (
ctx === undefined ||
@ -80,18 +129,84 @@ export function resolveSwiftImportTarget(
) {
return null;
}
return ctx;
}
// Swift import target is the SPM module name (first dotted segment).
const targetRaw = parsedImport.targetRaw;
if (targetRaw === null || targetRaw === '') return null;
const moduleName = targetRaw.split('.')[0];
/** Module files only — no @_exported closure. Null means external / unknown. */
function resolveSwiftModuleFiles(moduleName: string, ctx: SwiftResolveContext): string[] | null {
if (moduleName === '') return null;
const declared = coerceDeclaredSwiftTargets(ctx.resolutionConfig);
if (declared !== null) {
if (!declared.has(moduleName)) return null;
const files = getDeclaredFilesByName(ctx.allFilePaths, declared).get(moduleName);
if (files === undefined) return null;
const out = excludeImporter(files, ctx.fromFile);
return out.length > 0 ? out : null;
}
if (isSwiftSdkModule(moduleName)) return null;
const index = getSwiftModuleIndex(ctx.allFilePaths);
const files = index.byModule.get(moduleName);
if (files === undefined || files.length === 0) return null; // external framework
// Exclude the importer itself (a file under `Foo/` importing `Foo`).
const out = files.filter((f) => f !== ctx.fromFile);
if (files === undefined || files.length === 0) return null;
const out = excludeImporter(files, ctx.fromFile);
return out.length > 0 ? out : null;
}
function expandSwiftReexportFiles(seed: readonly string[], ctx: SwiftResolveContext): string[] {
const parsedFiles = ctx.parsedFiles;
if (parsedFiles === undefined || parsedFiles.length === 0) return [...seed];
if (!getSwiftReexportFlag(parsedFiles).hasReexport) return [...seed];
const byPath = getSwiftParsedByPath(parsedFiles);
const seenModules = new Set<string>();
const out = new Set(seed);
const queue = [...seed];
let head = 0;
while (head < queue.length) {
const file = queue[head++];
const parsed = byPath.get(file);
if (parsed === undefined) continue;
for (const imp of parsed.parsedImports) {
if (imp.kind !== 'reexport') continue;
const targetRaw = imp.targetRaw;
if (targetRaw === null) continue;
const moduleName = firstSwiftModuleSegment(targetRaw);
if (moduleName === null || seenModules.has(moduleName)) continue;
// `@_exported import struct Models.User` re-exports User, not Models.
// File-level resolve cannot attribute a member to a file, so skip
// the whole-module enqueue rather than painting every Models file.
if (imp.importedName !== moduleName) continue;
seenModules.add(moduleName);
const more = resolveSwiftModuleFiles(moduleName, ctx);
if (more === null) continue;
for (const next of more) {
if (out.has(next)) continue;
out.add(next);
queue.push(next);
}
}
}
return [...out];
}
export function resolveSwiftImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | readonly string[] | null {
const ctx = narrowContext(workspaceIndex);
if (ctx === null) return null;
const targetRaw = parsedImport.targetRaw;
if (targetRaw === null) return null;
const moduleName = firstSwiftModuleSegment(targetRaw);
if (moduleName === null) return null;
const files = resolveSwiftModuleFiles(moduleName, ctx);
if (files === null) return null;
const expanded = expandSwiftReexportFiles(files, ctx);
return expanded.length > 0 ? expanded : null;
}

View file

@ -19,17 +19,23 @@ export function interpretSwiftImport(captures: CaptureMatch): ParsedImport | nul
const sourceCap = captures['@import.source'];
if (sourceCap === undefined) return null;
// Swift imports are whole-module (wildcard semantics): `import Foundation`
// brings the entire module into scope, no named members. The SPM target
// (first dotted segment) is the resolution target; the full path is kept
// as importedName for reference. `@testable` resolves identically to a
// plain import (same module is visible in test scope).
const source = sourceCap.text;
const fullPath = captures['@import.name']?.text ?? source;
const name = captures['@import.name']?.text ?? source;
const kindText = captures['@import.kind']?.text;
if (kindText === 'reexport' || kindText === 'named') {
return {
kind: kindText,
localName: name,
importedName: name,
targetRaw: source,
};
}
return {
kind: 'namespace',
localName: source,
importedName: fullPath,
importedName: name,
targetRaw: source,
};
}

View file

@ -37,14 +37,15 @@
* Array where Element: Equatable`) are not narrowed — the `Self`
* type of a protocol method resolves to the protocol, not the
* conforming type.
* 2. **Cross-module `import` resolution** is still directory-segment
* based (`import Foo` → files under a `Foo/` dir); explicit imports do
* not yet consult the SPM target map (follow-up, tracked under #1935).
* Same-target visibility (the common case) IS SPM-target-subtree
* accurate — handled by sibling augmentation grouped via
* `groupSwiftFilesBySpmTarget`, not by explicit imports.
* 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.
* 3. **Operator / subscript overloads** dispatch by name only.
* 4. **`@_exported import` re-exports** are treated as plain imports.
* 4. **`@_exported import`** is `ParsedImport` `kind: 'reexport'` and
* in-repo modules are closed transitively at resolve time. `public
* import` is not a re-export.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
@ -87,12 +88,18 @@ const swiftScopeResolver: ScopeResolver = {
// `goScopeResolver`'s `loadGoModulePath`.
loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: SwiftResolveContext = { fromFile, allFilePaths };
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => {
const ws: SwiftResolveContext = {
fromFile,
allFilePaths,
resolutionConfig,
parsedFiles: context?.parsedFiles,
};
return resolveSwiftImportTarget(
interpretSwiftImport({
'@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE },
}) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw },
context?.parsedImport ??
interpretSwiftImport({
'@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE },
}) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw },
ws,
);
},

View file

@ -0,0 +1,29 @@
/**
* Well-known Apple / Swift SDK module names that must not bind to a
* same-named in-repo folder on the no-manifest path (#2964).
*
* Modeled on `CSHARP_EXTERNAL_ROOTS`. A Package.swift target that uses
* one of these names still wins — this set is only the inferred/null path.
*/
export const SWIFT_SDK_MODULES: ReadonlySet<string> = new Set([
'Foundation',
'UIKit',
'SwiftUI',
'AppKit',
'Combine',
'Dispatch',
'Darwin',
'ObjectiveC',
'Swift',
'CoreFoundation',
'CoreData',
'CoreGraphics',
'XCTest',
'Testing',
'Observation',
'os',
]);
export function isSwiftSdkModule(name: string): boolean {
return SWIFT_SDK_MODULES.has(name);
}

View file

@ -9,36 +9,31 @@
* drops cross-directory same-module edges and can mis-resolve a
* constructor call to a wrong same-simple-named type in another target.
*
* This module duplicates the legacy `groupSwiftFilesByTarget`
* (`languages/swift.ts`) semantics **verbatim** so the registry-primary
* path matches legacy SPM-subtree grouping without touching the legacy
* pipeline (hard constraint: legacy stays byte-identical). The SPM target
* map is threaded in via the `resolutionConfig` channel
* 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`.
*
* NOTE: This intentionally differs from the import-config module's
* leading-`startsWith` (`import-resolvers/configs/swift.ts`): that module
* fans a file out to EVERY matching target (a nested file can belong to
* multiple configured target dirs there), whereas legacy module grouping
* assigns each file to the FIRST matching target only (legacy `break`s) —
* one bucket per file. Do not copy the import-config behavior here.
* 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.
*/
import type { SwiftPackageConfig } from '../../language-config.js';
import { swiftDeclaredTargetPrefix, type SwiftPackageConfig } from '../../language-config.js';
export { coerceDeclaredSwiftTargets } from '../../language-config.js';
const DEFAULT_TARGET = '__default__';
/**
* Group `items` by SPM target subtree, replicating legacy
* `groupSwiftFilesByTarget` semantics exactly:
* 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>/` (`indexOf === 0`) OR contains it at a `/`
* boundary (`norm[idx - 1] === '/'`). Each file is assigned to the
* FIRST matching target only (one bucket per file, no fan-out).
* 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).
@ -53,10 +48,9 @@ export function groupSwiftFilesBySpmTarget<T>(
return new Map([[DEFAULT_TARGET, [...items]]]);
}
// Pre-convert target dirs to normalized prefix format once.
const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({
name,
prefix: dir.replace(/\\/g, '/') + '/',
prefix: swiftDeclaredTargetPrefix(dir),
}));
const groups = new Map<string, T[]>();
@ -67,8 +61,7 @@ export function groupSwiftFilesBySpmTarget<T>(
const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath;
let assigned = false;
for (const { name, prefix } of targetPrefixes) {
const idx = normalized.indexOf(prefix);
if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) {
if (pathMatchesTargetPrefix(normalized, prefix)) {
let group = groups.get(name);
if (group === undefined) {
group = [];
@ -102,3 +95,13 @@ 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}`);
}
/** Segment-boundary membership used by grouping and declared import resolve. */
export function fileMatchesSwiftTargetDir(normalizedPath: string, targetDir: string): boolean {
return pathMatchesTargetPrefix(normalizedPath, swiftDeclaredTargetPrefix(targetDir));
}

View file

@ -10,10 +10,13 @@
* 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`,
* replicating legacy `wireSwiftImplicitImports`'s `groupSwiftFilesByTarget`:
* files are grouped by SPM target subtree when a package config is present,
* else ALL Swift files form one module (`__default__`,
* `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.
*

View file

@ -485,6 +485,31 @@ describe('swiftPackageStrategy', () => {
const result = swiftPackageStrategy('Foundation', 'App.swift', ctx);
expect(result).toBeNull();
});
it('does not resolve inferred grouping folders when the declaration is empty', () => {
const ctx = makeCtx(['Sources/Foundation/Thing.swift', 'Sources/App/main.swift'], {
swiftPackageConfig: {
origin: 'package.swift',
targets: new Map([
['Foundation', 'Sources/Foundation'],
['App', 'Sources/App'],
]),
declaredTargets: new Map(),
},
});
expect(swiftPackageStrategy('Foundation', 'Sources/App/main.swift', ctx)).toBeNull();
expect(swiftPackageStrategy('App', 'Sources/App/main.swift', ctx)).toBeNull();
});
it('ignores an inferred directories origin', () => {
const ctx = makeCtx(['Sources/Models/User.swift'], {
swiftPackageConfig: {
origin: 'directories',
targets: new Map([['Models', 'Sources/Models']]),
},
});
expect(swiftPackageStrategy('Models', 'Sources/App/main.swift', ctx)).toBeNull();
});
});
describe('rubyRequireStrategy', () => {

View file

@ -336,7 +336,7 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
resolutionConfig: undefined,
external: 'Foundation',
decoy: 'Sources/Foundation/Thing.swift',
reachesDecoy: 'Models',
reachesDecoy: 'Sources',
},
],
[
@ -417,7 +417,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
*/
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
]);

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { groupSwiftFilesBySpmTarget } 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']]);
const items = ['vendor/SubModules/Core/shim/Modules/Core/Thing.swift'];
const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets);
expect(groups.get('Core')).toEqual(items);
expect(groups.get('__default__')).toBeUndefined();
});
it('keeps an earlier segment-aligned match when a later occurrence is partial', () => {
const targets = new Map([['Core', 'Modules/Core']]);
const items = ['Modules/Core/Thing/SubModules/Core/Thing.swift'];
const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets);
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);
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'],
]);
const item = 'Sources/Feature/Thing.swift';
const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets);
expect(groups.get('Outer')).toEqual([item]);
expect(groups.get('Inner')).toBeUndefined();
});
});

View file

@ -0,0 +1,74 @@
/**
* Intra-group implicit IMPORTS. @_exported is client-facing
* (`resolveSwiftImportTarget`); siblings in the exporting target
* must not gain edges to the reexported module.
*/
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 { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
const DECLARED = {
origin: 'package.swift' as const,
targets: new Map([
['A', 'Sources/A'],
['B', 'Sources/B'],
]),
};
function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile {
return {
filePath,
moduleScope: `module:${filePath}` as ScopeId,
scopes: [],
parsedImports,
localDefs: [],
referenceSites: [],
};
}
function reexport(targetRaw: string): ParsedImport {
return { kind: 'reexport', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function ns(targetRaw: string): ParsedImport {
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function importPair(rels: readonly { sourceId: string; targetId: string; type: string }[]) {
return rels
.filter((rel) => rel.type === 'IMPORTS')
.map((rel) => `${rel.sourceId}->${rel.targetId}`)
.sort();
}
describe('emitSwiftImplicitImportEdges', () => {
it('does not paint sibling files into a @_exported module', () => {
const a = 'Sources/A/A.swift';
const other = 'Sources/A/Other.swift';
const b = 'Sources/B/B.swift';
const parsed = [stubFile(a, [reexport('B')]), stubFile(other), stubFile(b)];
const graph = createKnowledgeGraph();
emitSwiftImplicitImportEdges(graph, parsed, new Map(), DECLARED);
const pairs = importPair(graph.relationships);
expect(pairs).toEqual(
[
`${generateId('File', a)}->${generateId('File', other)}`,
`${generateId('File', other)}->${generateId('File', a)}`,
].sort(),
);
expect(pairs.some((pair) => pair.includes(generateId('File', b)))).toBe(false);
const fromApp = resolveSwiftImportTarget(ns('A'), {
fromFile: 'Sources/App/main.swift',
allFilePaths: new Set([a, other, b, 'Sources/App/main.swift']),
resolutionConfig: DECLARED,
parsedFiles: parsed,
});
expect(fromApp).toEqual(expect.arrayContaining([a, other, b]));
});
});

View file

@ -0,0 +1,154 @@
/**
* Swift import capture: import-kind, @_exported, and module path (R5, R6).
*/
import { describe, expect, it } from 'vitest';
import { SupportedLanguages } from '../../../../src/config/supported-languages.js';
import { emitSwiftScopeCaptures } from '../../../../src/core/ingestion/languages/swift/index.js';
import { interpretSwiftImport } from '../../../../src/core/ingestion/languages/swift/interpret.js';
import { isLanguageAvailable } from '../../../../src/core/tree-sitter/parser-loader.js';
function importsOf(src: string) {
return emitSwiftScopeCaptures(src, 'Probe.swift')
.map((match) => interpretSwiftImport(match))
.filter((imp): imp is NonNullable<typeof imp> => imp !== null);
}
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
describe.skipIf(!swiftAvailable)('interpretSwiftImport via emitSwiftScopeCaptures', () => {
it('import Foundation is a namespace, not exported', () => {
expect(importsOf('import Foundation')).toEqual([
{
kind: 'namespace',
localName: 'Foundation',
importedName: 'Foundation',
targetRaw: 'Foundation',
},
]);
});
it('import struct Models.User is a named binding of User', () => {
expect(importsOf('import struct Models.User')).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('allows a block comment between import and its kind', () => {
expect(importsOf('import /* selected API */ struct Models.User')).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('allows a line comment between import and its kind', () => {
expect(importsOf('import // selected API\nstruct Models.User')).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('reads the kind after a long block comment that repeats star-slash-slash-star', () => {
const noise = '*//*'.repeat(80);
expect(importsOf(`import /* ${noise} */ struct Models.User`)).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('does not take import kind from an @available message string', () => {
expect(
importsOf('@available(*, deprecated, message: "import struct") import Foo.Bar'),
).toMatchObject([{ kind: 'namespace', targetRaw: 'Foo' }]);
});
it('preserves @testable as the same module', () => {
const [imp] = importsOf('@testable import App');
expect(imp).toMatchObject({
kind: 'namespace',
targetRaw: 'App',
});
});
it('@_exported import Models is a reexport of the module handle', () => {
expect(importsOf('@_exported import Models')).toEqual([
{
kind: 'reexport',
localName: 'Models',
importedName: 'Models',
targetRaw: 'Models',
},
]);
});
it('@_exported import struct Models.User is a reexport of User', () => {
expect(importsOf('@_exported import struct Models.User')).toEqual([
{
kind: 'reexport',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('public import Models is not a reexport', () => {
expect(importsOf('public import Models')).toEqual([
{
kind: 'namespace',
localName: 'Models',
importedName: 'Models',
targetRaw: 'Models',
},
]);
});
it('does not treat _exported inside an @available message as @_exported', () => {
expect(importsOf('@available(*, deprecated, message: "_exported") import Models')).toEqual([
{
kind: 'namespace',
localName: 'Models',
importedName: 'Models',
targetRaw: 'Models',
},
]);
});
it('reads the kind after a comment that itself contains import', () => {
expect(importsOf('import /* import */ struct Models.User')).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
it('reads the kind after nested block comments', () => {
expect(importsOf('import /* outer /* inner */ */ struct Models.User')).toEqual([
{
kind: 'named',
localName: 'User',
importedName: 'User',
targetRaw: 'Models',
},
]);
});
});

View file

@ -0,0 +1,129 @@
/**
* Registry-primary Swift import resolve (PR 3105 / #2964).
*/
import { describe, expect, it } from 'vitest';
import type { ParsedImport } from 'gitnexus-shared';
import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
const AE1_FILES = [
'Sources/Foundation/Thing.swift',
'Sources/Models/User.swift',
'Sources/App/main.swift',
] as const;
function ns(targetRaw: string): ParsedImport {
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function resolve(
targetRaw: string,
files: readonly string[] = AE1_FILES,
fromFile = 'Sources/App/main.swift',
resolutionConfig?: unknown,
): string | readonly string[] | null {
return resolveSwiftImportTarget(ns(targetRaw), {
fromFile,
allFilePaths: new Set(files),
resolutionConfig,
});
}
describe('resolveSwiftImportTarget — inferred / no config (R7)', () => {
it('AE1: Foundation is null; Models reaches User.swift', () => {
expect(resolve('Foundation')).toBeNull();
expect(resolve('Models')).toEqual(['Sources/Models/User.swift']);
});
it('AE6: UIKit / CoreData / CoreGraphics are null on inferred decoys', () => {
const files = [
...AE1_FILES,
'Sources/UIKit/Thing.swift',
'Sources/CoreData/Thing.swift',
'Sources/CoreGraphics/Thing.swift',
];
const inferred = {
origin: 'directories' as const,
targets: new Map([
['App', 'Sources/App'],
['Models', 'Sources/Models'],
['Foundation', 'Sources/Foundation'],
['UIKit', 'Sources/UIKit'],
['CoreData', 'Sources/CoreData'],
['CoreGraphics', 'Sources/CoreGraphics'],
]),
};
expect(resolve('UIKit', files, 'Sources/App/main.swift', inferred)).toBeNull();
expect(resolve('CoreData', files, 'Sources/App/main.swift', inferred)).toBeNull();
expect(resolve('CoreGraphics', files, 'Sources/App/main.swift', inferred)).toBeNull();
expect(resolve('Models', files, 'Sources/App/main.swift', inferred)).toEqual([
'Sources/Models/User.swift',
]);
});
});
describe('resolveSwiftImportTarget — declared Package.swift (R3, R4)', () => {
const declared = {
origin: 'package.swift' as const,
targets: new Map([
['Models', 'Sources/Models'],
['App', 'Sources/App'],
]),
};
it('AE2: Models reaches files; Foundation is null', () => {
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', declared)).toEqual([
'Sources/Models/User.swift',
]);
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', declared)).toBeNull();
});
it('empty declared map makes every name external, including Foundation', () => {
const empty = { origin: 'package.swift' as const, targets: new Map<string, string>() };
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull();
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull();
});
it('empty declaredTargets with inferred grouping folders stays external', () => {
const loadedShape = {
origin: 'package.swift' as const,
targets: new Map([
['Foundation', 'Sources/Foundation'],
['App', 'Sources/App'],
['Models', 'Sources/Models'],
]),
declaredTargets: new Map<string, string>(),
};
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull();
expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull();
});
it('a declared target literally named Foundation still resolves', () => {
const namedFoundation = {
origin: 'package.swift' as const,
targets: new Map([['Foundation', 'Sources/Foundation']]),
};
expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', namedFoundation)).toEqual([
'Sources/Foundation/Thing.swift',
]);
});
it('a declared path: "." target reaches root-level Swift files', () => {
const files = ['Lib.swift', 'Sources/Other/X.swift'];
expect(
resolve('Lib', files, 'Sources/App/main.swift', {
origin: 'package.swift',
targets: new Map([['Lib', '.']]),
}),
).toEqual(['Lib.swift', 'Sources/Other/X.swift']);
});
it('excludes the importer from its own module file list', () => {
const files = ['Sources/Models/User.swift', 'Sources/Models/Other.swift'];
expect(
resolve('Models', files, 'Sources/Models/User.swift', {
origin: 'package.swift',
targets: new Map([['Models', 'Sources/Models']]),
}),
).toEqual(['Sources/Models/Other.swift']);
});
});

View file

@ -0,0 +1,452 @@
/**
* Package.swift target loading (PR 3105 / #2964).
*
* `loadSwiftPackageConfig` must distinguish a declaration map
* (`origin: 'package.swift'`) from an inferred `Sources/*` folder map
* (`origin: 'directories'`). Grouping uses either; explicit import
* resolve uses only the declared origin.
*/
import { afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
loadSwiftPackageConfig,
parseSwiftPackageManifest,
swiftDeclaredTargetPrefix,
} from '../../../../src/core/ingestion/language-config.js';
import { coerceDeclaredSwiftTargets } from '../../../../src/core/ingestion/languages/swift/target-grouping.js';
const roots: string[] = [];
function repo(files: Readonly<Record<string, string | null>>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-pkg-'));
roots.push(root);
for (const [rel, contents] of Object.entries(files)) {
const full = path.join(root, rel);
if (contents === null) {
fs.mkdirSync(full, { recursive: true });
continue;
}
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, contents);
}
return root;
}
afterAll(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
const MODELS_APP = `
let package = Package(
name: "Demo",
targets: [
.target(name: "Models"),
.target(name: "App"),
]
)
`;
describe('parseSwiftPackageManifest', () => {
it('maps .target(name:) with no path to Sources/<name>', () => {
const parsed = parseSwiftPackageManifest(MODELS_APP);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
expect(parsed.targets.get('App')).toBe('Sources/App');
});
it('honors an explicit path:', () => {
const src = `
.target(name: "Core", path: "Modules/Core")
`;
const parsed = parseSwiftPackageManifest(src);
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
complete: true,
entries: [['Core', 'Modules/Core']],
});
});
it('maps .testTarget to Tests/<name>', () => {
const parsed = parseSwiftPackageManifest(`.testTarget(name: "AppTests")`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('AppTests')).toBe('Tests/AppTests');
});
it('skips binary / plugin / systemLibrary targets', () => {
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('treats #if as a completeness hazard', () => {
const parsed = parseSwiftPackageManifest(`
#if os(macOS)
.target(name: "MacOnly")
#endif
.target(name: "Models")
`);
expect(parsed.complete).toBe(false);
});
it('treats a helper-built targets: list as incomplete', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(name: "Demo", targets: makeTargets())
`);
expect(parsed.complete).toBe(false);
});
it('treats a computed name: as incomplete', () => {
const parsed = parseSwiftPackageManifest(`.target(name: targetName)`);
expect(parsed.complete).toBe(false);
});
it('treats a computed path: as incomplete', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: corePath)`);
expect(parsed.complete).toBe(false);
});
it('ignores a block-commented factory', () => {
const parsed = parseSwiftPackageManifest(`
/* .target(name: "Ghost") */
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('records path: "." as the package root', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "Lib", path: ".")`);
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
complete: true,
entries: [['Lib', '.']],
});
});
it('ignores a // commented factory', () => {
const parsed = parseSwiftPackageManifest(`
// .target(name: "Ghost")
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('does not treat a dependency .target(name:) as a declared target', () => {
const parsed = parseSwiftPackageManifest(`
.target(name: "App", dependencies: [.target(name: "Core")])
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('App')).toBe('Sources/App');
expect(parsed.targets.has('Core')).toBe(false);
});
it('prefers an explicit path over an earlier same-name factory', () => {
const parsed = parseSwiftPackageManifest(`
.target(name: "Core")
.target(name: "Core", path: "Modules/Core")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Core')).toBe('Modules/Core');
});
it('treats a string-interpolated path as incomplete', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: "Modules/\\(name)")`);
expect(parsed.complete).toBe(false);
});
it('treats a parenthesized name string as a complete factory', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "Foo (experimental)")`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Foo (experimental)')).toBe('Sources/Foo (experimental)');
});
it('ignores a commented parenthesis while balancing a factory', () => {
const parsed = parseSwiftPackageManifest(`
.target(name: "Core", // )
path: "Modules/Core")
`);
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
complete: true,
entries: [['Core', 'Modules/Core']],
});
});
it('ignores a block-comment parenthesis while balancing a factory', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "Core", /* ) */ path: "Modules/Core")`);
expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({
complete: true,
entries: [['Core', 'Modules/Core']],
});
});
it('treats a mixed literal + helper-built targets: list as incomplete', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(name: "Demo", targets: [.target(name: "Core")] + makeTargets())
`);
expect(parsed.complete).toBe(false);
});
it('treats a helper-built list concatenated before literals as incomplete', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(name: "Demo", targets: makeTargets() + [.target(name: "Core")])
`);
expect(parsed.complete).toBe(false);
});
it('does not treat .library(..., targets: names) as a helper-built list', () => {
const parsed = parseSwiftPackageManifest(`
.library(name: "Demo", targets: libTargets)
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('ignores a factory-like spelling inside a string literal', () => {
const parsed = parseSwiftPackageManifest(`
let example = ".target(name: 'Ghost')"
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('treats a variable-prefix targets: concatenation as incomplete', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(name: "Demo", targets: extraTargets + [.target(name: "Core")])
`);
expect(parsed.complete).toBe(false);
});
it('skips a commented name: field and uses the real one', () => {
const parsed = parseSwiftPackageManifest(`.target(/* name: "Ghost" */ name: "Models")`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('does not treat a commented #if as a completeness hazard', () => {
const parsed = parseSwiftPackageManifest(`
// #if os(macOS)
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('does not treat a block-commented #if as a completeness hazard', () => {
const parsed = parseSwiftPackageManifest(`
/*
#if os(macOS)
.target(name: "MacOnly")
#endif
*/
.target(name: "Models")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('MacOnly')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('rejects a name string with a Swift escape', () => {
const parsed = parseSwiftPackageManifest(`.target(name: "\\u{43}ore")`);
expect(parsed.complete).toBe(false);
expect(parsed.targets.size).toBe(0);
});
it('does not treat https:// on the same line as a commented factory', () => {
const parsed = parseSwiftPackageManifest(
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('T')).toBe('Sources/T');
});
it('ignores a commented helper-built targets: list', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(
name: "Demo",
targets: [
.target(name: "Models"),
]
)
// targets: makeTargets()
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('ignores a helper-built targets: spelling inside a string', () => {
const parsed = parseSwiftPackageManifest(`
let note = "targets: makeTargets()"
let package = Package(name: "Demo", targets: [.target(name: "Models")])
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
it('treats Package(targets: variable) as incomplete even when a factory was collected', () => {
const parsed = parseSwiftPackageManifest(`
func unused() { _ = Target.target(name: "Ghost") }
let package = Package(name: "Demo", targets: actualTargets)
.target(name: "Incidental")
`);
expect(parsed.complete).toBe(false);
expect(parsed.targets.size).toBe(0);
});
it('does not collect a factory outside Package(targets: [...])', () => {
const parsed = parseSwiftPackageManifest(`
func unused() { _ = Target.target(name: "Ghost") }
let package = Package(name: "Demo", targets: [.target(name: "Incidental")])
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Incidental')).toBe('Sources/Incidental');
});
it('treats a computed element inside the targets: array as incomplete', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(name: "Demo", targets: [makeTargets()])
`);
expect(parsed.complete).toBe(false);
expect(parsed.targets.size).toBe(0);
});
it('does not collect factories when Package omits targets:', () => {
const parsed = parseSwiftPackageManifest(`
let unused = Target.target(name: "Ghost")
let package = Package(name: "Empty")
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.size).toBe(0);
});
it('does not treat a line comment after a label colon as live source', () => {
const parsed = parseSwiftPackageManifest(`
let package = Package(
name: "Demo",
targets: [
.target(name: // .target(name: "Ghost")
"Models"),
]
)
`);
expect(parsed.complete).toBe(true);
expect(parsed.targets.has('Ghost')).toBe(false);
expect(parsed.targets.get('Models')).toBe('Sources/Models');
});
});
describe('swiftDeclaredTargetPrefix', () => {
it('treats . and ./ as the package root', () => {
expect(swiftDeclaredTargetPrefix('.')).toBe('');
expect(swiftDeclaredTargetPrefix('./')).toBe('');
expect(swiftDeclaredTargetPrefix('./.')).toBe('');
});
it('strips a leading ./ from a relative target path', () => {
expect(swiftDeclaredTargetPrefix('./Sources/Core')).toBe('Sources/Core/');
expect(swiftDeclaredTargetPrefix('Sources/Core')).toBe('Sources/Core/');
});
});
describe('loadSwiftPackageConfig', () => {
it('returns a declared map from Package.swift and ignores undeclared Sources/* folders', async () => {
const root = repo({
'Package.swift': MODELS_APP,
'Sources/Models/User.swift': '',
'Sources/App/main.swift': '',
'Sources/Foundation/Thing.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('package.swift');
expect([...cfg!.targets.keys()].sort()).toEqual(['App', 'Models']);
expect(cfg!.targets.has('Foundation')).toBe(false);
});
it('returns an empty declared map when the manifest only has skipped target kinds', async () => {
const root = repo({
'Package.swift': `
let package = Package(
name: "OnlyBinary",
targets: [.binaryTarget(name: "Lib", path: "Lib.xcframework")]
)
`,
'Sources/Foundation/Thing.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('package.swift');
expect(cfg!.declaredTargets?.size).toBe(0);
// Grouping still uses inferred folders so App/Foundation stay isolated.
expect(cfg!.targets.get('Foundation')).toBe('Sources/Foundation');
expect(coerceDeclaredSwiftTargets(cfg)?.size).toBe(0);
});
it('infers Sources/* folders when Package.swift is missing', async () => {
const root = repo({
'Sources/App/main.swift': '',
'Sources/Models/User.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('directories');
expect(cfg!.targets.get('App')).toBe('Sources/App');
expect(cfg!.targets.get('Models')).toBe('Sources/Models');
});
it('infers directories when Package.swift is unreadable (is a directory)', async () => {
const root = repo({
'Package.swift': null,
'Sources/App/main.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('directories');
expect(cfg!.targets.get('App')).toBe('Sources/App');
});
it('infers directories when the manifest has completeness hazards', async () => {
const root = repo({
'Package.swift': `
#if os(Linux)
.target(name: "LinuxOnly")
#endif
`,
'Sources/App/main.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('directories');
expect(cfg!.targets.get('App')).toBe('Sources/App');
expect(cfg!.targets.has('LinuxOnly')).toBe(false);
});
it('returns null when there is no manifest and no source folders', async () => {
const root = repo({ 'README.md': '' });
expect(await loadSwiftPackageConfig(root)).toBeNull();
});
it('declares a one-line manifest that includes an https:// dependency URL', async () => {
const root = repo({
'Package.swift':
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
'Sources/T/T.swift': '',
});
const cfg = await loadSwiftPackageConfig(root);
expect(cfg?.origin).toBe('package.swift');
expect(cfg!.declaredTargets?.get('T')).toBe('Sources/T');
expect(cfg!.targets.get('T')).toBe('Sources/T');
});
});

View file

@ -0,0 +1,131 @@
/**
* In-repo @_exported visibility (R5 / AE4).
*/
import { describe, expect, it } from 'vitest';
import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared';
import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js';
const DECLARED = {
origin: 'package.swift' as const,
targets: new Map([
['A', 'Sources/A'],
['B', 'Sources/B'],
['C', 'Sources/C'],
]),
};
function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile {
return {
filePath,
moduleScope: `module:${filePath}` as ScopeId,
scopes: [],
parsedImports,
localDefs: [],
referenceSites: [],
};
}
function reexport(targetRaw: string, name = targetRaw): ParsedImport {
return { kind: 'reexport', localName: name, importedName: name, targetRaw };
}
function ns(targetRaw: string): ParsedImport {
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function resolve(
targetRaw: string,
files: readonly string[],
parsedFiles: ParsedFile[],
fromFile = 'Sources/App/main.swift',
resolutionConfig: unknown = DECLARED,
): string | readonly string[] | null {
return resolveSwiftImportTarget(ns(targetRaw), {
fromFile,
allFilePaths: new Set(files),
resolutionConfig,
parsedFiles,
});
}
describe('Swift @_exported visibility', () => {
it('AE4: import A includes B when A @_exported import B', () => {
const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/App/main.swift'];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('B')]),
stubFile('Sources/B/B.swift'),
stubFile('Sources/App/main.swift'),
];
expect(resolve('A', files, parsed)).toEqual(
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']),
);
});
it('transitive A → B → C unions C', () => {
const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift'];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('B')]),
stubFile('Sources/B/B.swift', [reexport('C')]),
stubFile('Sources/C/C.swift'),
];
expect(resolve('A', files, parsed, 'Sources/App/main.swift')).toEqual(
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']),
);
});
it('cycle A → B → A terminates', () => {
const files = ['Sources/A/A.swift', 'Sources/B/B.swift'];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('B')]),
stubFile('Sources/B/B.swift', [reexport('A')]),
];
expect(resolve('A', files, parsed)).toEqual(
expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']),
);
});
it('@_exported import Foundation adds no files', () => {
const files = ['Sources/A/A.swift', 'Sources/Foundation/Thing.swift', 'Sources/App/main.swift'];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('Foundation')]),
stubFile('Sources/Foundation/Thing.swift'),
];
expect(resolve('A', files, parsed)).toEqual(['Sources/A/A.swift']);
});
it('member-only @_exported import does not paint the rest of the module', () => {
const files = [
'Sources/A/A.swift',
'Sources/Models/User.swift',
'Sources/Models/Other.swift',
'Sources/App/main.swift',
];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('Models', 'User')]),
stubFile('Sources/Models/User.swift'),
stubFile('Sources/Models/Other.swift'),
];
const declared = {
origin: 'package.swift' as const,
targets: new Map([
['A', 'Sources/A'],
['Models', 'Sources/Models'],
]),
};
expect(resolve('A', files, parsed, 'Sources/App/main.swift', declared)).toEqual([
'Sources/A/A.swift',
]);
});
it('a sibling file that does not write @_exported still contributes the reexport', () => {
const files = ['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift'];
const parsed = [
stubFile('Sources/A/A.swift', [reexport('B')]),
stubFile('Sources/A/Other.swift'),
stubFile('Sources/B/B.swift'),
];
expect(resolve('A', files, parsed)).toEqual(
expect.arrayContaining(['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']),
);
});
});

View file

@ -3,18 +3,19 @@
* KTD2).
*
* `groupSwiftFilesBySpmTarget` (`languages/swift/target-grouping.ts`)
* DUPLICATES the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`)
* SPM-subtree semantics so the registry-primary same-module hooks group by
* the SPM target subtree without touching the legacy pipeline (hard
* constraint: legacy stays byte-identical). Because the duplication can
* silently drift if legacy is later changed, this test pins the exact
* bucketing for representative inputs so a future divergence surfaces
* loudly:
* 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.
*
* 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. Unmatched files AND the no-targets case route to `__default__` = all.
* 3. Target dirs match only at path-segment boundaries.
* 4. Unmatched files AND the no-targets case route to `__default__` = all.
*
* `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }`
* (no `instanceof` on the config object) and returns `null` otherwise.
@ -27,7 +28,7 @@ import {
const id = (s: string) => s;
describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)', () => {
describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => {
it('buckets a multi-subdir single target into ONE group', () => {
const files = [
'Sources/Alpha/Core/User.swift',
@ -45,7 +46,7 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
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). Legacy `break`s on the first match → one bucket per file.
// Alpha). The first configured match wins → one bucket per file.
const files = ['Sources/Alpha/Beta/User.swift'];
const targets = new Map([
['Alpha', 'Sources/Alpha'],
@ -58,9 +59,19 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
expect(groups.has('Beta')).toBe(false);
});
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);
expect(groups.get('Lib')).toEqual(files);
expect(groups.has('__default__')).toBe(false);
});
it('matches a target dir only at a `/` boundary, not a substring', () => {
// "Sources/Alpha" must NOT match "Sources/AlphaBeta/..." — the legacy
// predicate requires idx===0 or a preceding `/`.
// "Sources/Alpha" must NOT match "Sources/AlphaBeta/...". The matcher
// accepts only a path-start or slash-delimited target occurrence.
const files = ['Sources/AlphaBeta/User.swift'];
const targets = new Map([['Alpha', 'Sources/Alpha']]);
@ -114,6 +125,26 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)
expect(groups.get('Beta')).toEqual([items[1]]);
});
it('keeps inferred Sources/* folders in separate buckets', () => {
const targets = new Map([
['App', 'Sources/App'],
['Models', 'Sources/Models'],
['Foundation', 'Sources/Foundation'],
]);
const items = [
'Sources/App/main.swift',
'Sources/Models/User.swift',
'Sources/Foundation/Thing.swift',
];
const groups = groupSwiftFilesBySpmTarget(items, id, targets);
expect(groups.get('App')).toEqual(['Sources/App/main.swift']);
expect(groups.get('Models')).toEqual(['Sources/Models/User.swift']);
expect(groups.get('Foundation')).toEqual(['Sources/Foundation/Thing.swift']);
expect(groups.get('__default__')).toBeUndefined();
});
it('normalizes backslash paths to forward-slash before matching', () => {
const files = ['Sources\\Alpha\\Core\\User.swift'];
const targets = new Map([['Alpha', 'Sources/Alpha']]);