From 597398fc755718ce1d1814237eccb4b8bae2c278 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Mon, 31 Aug 2026 08:00:02 +0530 Subject: [PATCH 1/6] fix(swift): match repeated SPM target prefixes --- .../languages/swift/target-grouping.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts index 5f26d3595..7ee2a85ea 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts @@ -9,20 +9,15 @@ * 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. + * multiple configured target dirs there), whereas module grouping assigns + * each file to the FIRST matching target only — one bucket per file. */ import type { SwiftPackageConfig } from '../../language-config.js'; @@ -30,15 +25,16 @@ import type { SwiftPackageConfig } 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). @@ -67,8 +63,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 (normalized.startsWith(prefix) || normalized.includes(`/${prefix}`)) { let group = groups.get(name); if (group === undefined) { group = []; From d449fc3b5a1ecd651dfd06a01f5751a3c88ea400 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Mon, 31 Aug 2026 08:00:09 +0530 Subject: [PATCH 2/6] test(swift): cover repeated SPM target prefixes --- .../swift-target-grouping.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts 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..5b183ae47 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -0,0 +1,37 @@ +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('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(); + }); +}); From b73dd1f3c56c1372588a39684d0c1374cefd1448 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Tue, 1 Sep 2026 08:02:30 +0530 Subject: [PATCH 3/6] test(swift): cover valid prefix before later partial match --- .../scope-resolution/swift-target-grouping.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 5b183ae47..f8e139968 100644 --- a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -12,6 +12,16 @@ describe('groupSwiftFilesBySpmTarget', () => { 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'; From 6a4e94993ac39c88afc30c9a05e6752306ef6033 Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Wed, 2 Sep 2026 07:59:04 +0530 Subject: [PATCH 4/6] docs(swift): clarify target grouping parity scope --- .../swift/target-grouping.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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..0b3d47fc8 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,20 @@ * 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. The legacy pipeline + * remains byte-identical; these tests pin the shared behavior that must not + * drift while allowing that documented correctness 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 +29,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 +47,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'], @@ -59,8 +61,8 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) }); 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']]); From 4b300dd2f0a58a1c971237f2738504ec8f8e7efe Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Wed, 2 Sep 2026 07:59:26 +0530 Subject: [PATCH 5/6] docs(swift): clarify target grouping parity scope --- .../core/ingestion/languages/swift/target-siblings.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts index 47ccc4109..2fc982c62 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-siblings.ts @@ -10,10 +10,12 @@ * Module identity: Swift has no in-source `package X` marker. The SPM * target is a directory subtree (`Sources//…`). 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 package config is present, 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. * From 9a5637dcfede0ff423d92e091bc39491fe1c766c Mon Sep 17 00:00:00 2001 From: Twisted_Arrow Date: Wed, 2 Sep 2026 07:59:41 +0530 Subject: [PATCH 6/6] docs(swift): clarify target grouping parity scope --- .../ingestion/languages/swift/implicit-imports.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts index a850045b8..ca68c6066 100644 --- a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts +++ b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts @@ -14,12 +14,13 @@ * 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 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). + * 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 + * scanned source dir 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). * * Node identity + edge construction mirror the generic `emitImportEdges` * convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)`