From dcb2eb5cb4a395c8c2a34fca068ea859b7563073 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Sat, 19 Sep 2026 20:20:31 +0530 Subject: [PATCH] fix(swift): resolve imports from Package.swift targets, not path segments (#3105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * fix(ci): prettier the @_exported availability fixture Co-authored-by: Cursor * 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 * 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 * Address PR review feedback (#3105) Ignore stray factories when Package() exists but omits targets:. Co-authored-by: Cursor * Address PR review feedback (#3105) Require the Package-scan seen box so the always-true undefined guard goes away. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 11 + gitnexus/bench/import-target/baselines.json | 5 + gitnexus/bench/import-target/measure.mjs | 26 +- .../swift-package-imports/baselines.json | 52 ++ .../bench/swift-package-imports/measure.mjs | 475 ++++++++++ .../import-resolvers/configs/swift.ts | 58 +- .../src/core/ingestion/language-config.ts | 849 +++++++++++++++++- .../languages/swift/implicit-imports.ts | 26 +- .../languages/swift/import-decomposer.ts | 234 ++++- .../languages/swift/import-target.ts | 191 +++- .../ingestion/languages/swift/interpret.ts | 20 +- .../languages/swift/scope-resolver.ts | 31 +- .../ingestion/languages/swift/sdk-modules.ts | 29 + .../languages/swift/target-grouping.ts | 45 +- .../languages/swift/target-siblings.ts | 11 +- .../test/unit/import-resolver-factory.test.ts | 25 + .../external-import-conformance.test.ts | 3 +- .../swift-target-grouping.test.ts | 47 + .../swift/implicit-imports.test.ts | 74 ++ .../swift/import-decomposer.test.ts | 154 ++++ .../swift/import-target.test.ts | 129 +++ .../swift/package-config.test.ts | 452 ++++++++++ .../swift/reexport-visibility.test.ts | 131 +++ .../swift/target-grouping.test.ts | 55 +- 24 files changed, 2959 insertions(+), 174 deletions(-) create mode 100644 gitnexus/bench/swift-package-imports/baselines.json create mode 100644 gitnexus/bench/swift-package-imports/measure.mjs create mode 100644 gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/import-target.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/package-config.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6f4bfdbec..bbf5d01af 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 37fa78535..7b9a9932d 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -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, diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 35c8361a1..b66932d77 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -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. // diff --git a/gitnexus/bench/swift-package-imports/baselines.json b/gitnexus/bench/swift-package-imports/baselines.json new file mode 100644 index 000000000..2163b9f72 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/baselines.json @@ -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." +} diff --git a/gitnexus/bench/swift-package-imports/measure.mjs b/gitnexus/bench/swift-package-imports/measure.mjs new file mode 100644 index 000000000..6d41728b6 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/measure.mjs @@ -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}->`; + 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.'); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 05e4b13be..67e88dd32 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -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 `/`, 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 `/` or contains + * `//`. `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(); 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 = { diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 390e6a32f..759eee48f 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -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; + /** + * `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; +} + +/** + * 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 | null { + const config = resolutionConfig as Partial | 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 { - // Swift imports are module-name based (e.g., `import SiuperModel`) - // SPM convention: Sources// or Package/Sources// - // We scan for these directories to build a target map - const targets = new Map(); +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(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; + complete: boolean; +} { + const targets = new Map(); + 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([ + ...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> { + const targets = new Map(); const sourceDirs = ['Sources', 'Package/Sources', 'src']; for (const sourceDir of sourceDirs) { try { @@ -636,12 +1438,41 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise 0) { - if (isDev) { - logger.info(`📦 Loaded ${targets.size} Swift package targets`); +export async function loadSwiftPackageConfig(repoRoot: string): Promise { + 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; } diff --git a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts index a850045b8..b11f416cf 100644 --- a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts +++ b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts @@ -14,12 +14,15 @@ * Module identity: Swift has no in-source `package X` marker. Module * membership is the SPM target *subtree* (`Sources//…`), threaded * in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`) - * and grouped by `groupSwiftFilesBySpmTarget` — 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', diff --git a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts index b0cdfdd9c..a1fc03d2c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts @@ -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 = { '@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'); diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index e0d5e8219..603f2e6f7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -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//*.swift` (and the common - * `/*.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; + readonly resolutionConfig?: unknown; + readonly parsedFiles?: readonly ParsedFile[]; } interface SwiftModuleIndex { @@ -40,16 +30,17 @@ interface SwiftModuleIndex { readonly byModule: Map; } +interface SwiftDeclaredFileIndex { + readonly declared: ReadonlyMap; + readonly byName: ReadonlyMap; +} + const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): SwiftModuleIndex => { const byModule = new Map(); 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): Swif return { byModule }; }); -export function resolveSwiftImportTarget( - parsedImport: ParsedImport, - workspaceIndex: WorkspaceIndex, -): string | readonly string[] | null { +const SWIFT_DECLARED_INDEX = new WeakMap, SwiftDeclaredFileIndex>(); + +function getDeclaredFilesByName( + allFilePaths: ReadonlySet, + declared: ReadonlyMap, +): ReadonlyMap { + 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(); + 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 => { + const byPath = new Map(); + 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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/interpret.ts b/gitnexus/src/core/ingestion/languages/swift/interpret.ts index a12816843..c7accca32 100644 --- a/gitnexus/src/core/ingestion/languages/swift/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/swift/interpret.ts @@ -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, }; } diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 4b8f974c5..f0475f1cb 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -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, ); }, diff --git a/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts new file mode 100644 index 000000000..8d7f052e5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts @@ -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 = 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); +} diff --git a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts index 5f26d3595..a0572202c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts @@ -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 `/` (`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 `/` or contains `//` at a segment + * boundary. Using a segment-aware suffix search matters when an earlier, + * non-boundary occurrence of the same text appears in the path (#2931). + * - Each file is assigned to the FIRST matching target only (one bucket per + * file, no fan-out). * - Files matching no target fall into the `__default__` bucket. * * `targets` is `name → directory` (the `SwiftPackageConfig.targets` map). @@ -53,10 +48,9 @@ export function groupSwiftFilesBySpmTarget( 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(); @@ -67,8 +61,7 @@ export function groupSwiftFilesBySpmTarget( 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/…`). 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. * diff --git a/gitnexus/test/unit/import-resolver-factory.test.ts b/gitnexus/test/unit/import-resolver-factory.test.ts index 2e22790e0..90c0c5d95 100644 --- a/gitnexus/test/unit/import-resolver-factory.test.ts +++ b/gitnexus/test/unit/import-resolver-factory.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index c1932e02c..3e56563f1 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -336,7 +336,7 @@ const CASES: ReadonlyMap = new Map([ resolutionConfig: undefined, external: 'Foundation', decoy: 'Sources/Foundation/Thing.swift', - reachesDecoy: 'Models', + reachesDecoy: 'Sources', }, ], [ @@ -417,7 +417,6 @@ const CASES: ReadonlyMap = new Map([ */ const KNOWN_GAPS: ReadonlyMap = new Map([ [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`'], ]); diff --git a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts new file mode 100644 index 000000000..f8e139968 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts new file mode 100644 index 000000000..50cb49099 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts @@ -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])); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts new file mode 100644 index 000000000..aeded6eaf --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts @@ -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 => 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', + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts new file mode 100644 index 000000000..4bbe2f732 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts @@ -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() }; + 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(), + }; + 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']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts new file mode 100644 index 000000000..03ef37247 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts @@ -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>): 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/', () => { + 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/', () => { + 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'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts new file mode 100644 index 000000000..8b901e387 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts @@ -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']), + ); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts index f661dd21f..95d53bb43 100644 --- a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts @@ -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']]);