GitNexus/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts
Gergő Magyar 7d40156003
feat(swift): migrate Swift to scope-based registry resolution (#937) (#1948)
* feat(swift): migrate Swift to scope-based registry resolution (#937)

Ring 3 of RFC #909 — Swift is the final language migrated to the
scope-based registry resolution pipeline. Flips Swift into
MIGRATED_LANGUAGES so registry-primary call resolution is the production
default, with dual-mode parity proven: the resolver suite passes 77/77
under both the legacy DAG (REGISTRY_PRIMARY_SWIFT=0) and the
registry-primary path (REGISTRY_PRIMARY_SWIFT=1).

New language module src/core/ingestion/languages/swift/ (mirrors csharp/):
query, captures, interpret, import-decomposer, receiver-binding,
signature-bindings, arity (+metadata), merge-bindings, simple-hooks,
import-target, target-siblings, implicit-imports, sibling-type-bindings,
scope-resolver, cache-stats, index. Parse-time hooks wired into the
existing flat languages/swift.ts (coexists with the swift/ dir, like
kotlin) and the resolver registered in the scope-resolution registry.

tree-sitter-swift 0.7.1 specifics handled in the Swift module (not in
shared code):
- class / struct / extension all parse to class_declaration; extensions
  are re-keyed onto the extended type so members hoist (like C# partial).
- if-let / guard-let have no if_let_binding node — the optional binding
  is synthesized from if_statement / guard_statement.
- the name: field is reused for func name, param labels, param types and
  return type, so param/return type-bindings are synthesized in code
  (signature-bindings.ts) rather than via a multi-name query.
- no `new` keyword: Type(...) and Type.init(...) are synthesized into
  constructor type-bindings.

Shared-pipeline additions are language-agnostic (AGENTS.md: no language
names in shared ingestion code):
- constructorCallTargetsClass on the ScopeResolver contract +
  free-call-fallback option + run.ts wiring: when true, Type(...) links
  to the Class def rather than its explicit init Constructor.
- pickUniqueGlobalClass: constructor-branch global fallback for
  cross-file types absent from the call site's lexical bindings, deduped
  by qualifiedName so extension/partial fragments aren't seen as
  ambiguous.
- emitImplicitImportEdges: same-module File->File IMPORTS edges (Swift
  whole-module visibility has no syntactic import to drive the generic
  ImportEdge pipeline).

Import resolution: rewrote the O(n^2) module scan in
import-resolvers/configs/swift.ts with a WeakMap-memoized index.

Benchmarks & guards:
- Swift added to bench/scope-capture/measure.mjs + baselines.json
  (fingerprint + 1.5x scaling budget); `--check` passes for all 7
  languages, Swift scaling 0.98 (linear).
- golden capture-parity test
  (test/unit/scope-resolution/swift/swift-captures-golden.test.ts +
  fixtures/swift-captures-golden/) mirrors the csharp golden.
- O(n) scope-capture tripwire
  (test/integration/swift-scope-capture-tripwire.test.ts).

Full Swift test glob: 3 files / 88 tests pass; tsc --noEmit clean; no
cross-language resolver regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(swift): green CI — Dart canary, cascade-safe availability test, prettier, comment/order nits (U1)

* test(swift): wire createResolverParityIt('swift') + empty legacy skip-set (U2)

* fix(swift): group same-module files by SPM target subtree in registry-primary hooks (U3)

* fix(swift): correct member-write, class-func self, multi-clause if-let, nested-extension (U4)

* perf(scope-resolution): build global class index once for pickUniqueGlobalClass (U5)

* fix(swift): re-baseline scope-capture fingerprint after member-write capture change (U4)

* style(swift): prettier-format pick-unique-global-class test (U5 follow-up)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-31 16:56:47 +01:00

140 lines
5.4 KiB
TypeScript

/**
* Drift-guard unit test for `groupSwiftFilesBySpmTarget` (issue #1948 U3,
* 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:
*
* 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.
*
* `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }`
* (no `instanceof` on the config object) and returns `null` otherwise.
*/
import { describe, it, expect } from 'vitest';
import {
groupSwiftFilesBySpmTarget,
coerceSwiftTargets,
} from '../../../../src/core/ingestion/languages/swift/target-grouping.js';
const id = (s: string) => s;
describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)', () => {
it('buckets a multi-subdir single target into ONE group', () => {
const files = [
'Sources/Alpha/Core/User.swift',
'Sources/Alpha/Entry/App.swift',
'Sources/Alpha/Util/Helpers.swift',
];
const targets = new Map([['Alpha', 'Sources/Alpha']]);
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
expect([...groups.keys()]).toEqual(['Alpha']);
expect(groups.get('Alpha')).toEqual(files);
expect(groups.has('__default__')).toBe(false);
});
it('assigns a file matching two overlapping same-named prefixes to the FIRST target only', () => {
// Both targets are prefixes of the file's path (Beta dir nested under
// Alpha). Legacy `break`s on the first match → one bucket per file.
const files = ['Sources/Alpha/Beta/User.swift'];
const targets = new Map([
['Alpha', 'Sources/Alpha'],
['Beta', 'Sources/Alpha/Beta'],
]);
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
expect(groups.get('Alpha')).toEqual(files);
expect(groups.has('Beta')).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 `/`.
const files = ['Sources/AlphaBeta/User.swift'];
const targets = new Map([['Alpha', 'Sources/Alpha']]);
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
expect(groups.has('Alpha')).toBe(false);
expect(groups.get('__default__')).toEqual(files);
});
it('routes unmatched files (with targets present) to __default__', () => {
const files = ['Sources/Alpha/User.swift', 'Loose/Orphan.swift'];
const targets = new Map([['Alpha', 'Sources/Alpha']]);
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
expect(groups.get('Alpha')).toEqual(['Sources/Alpha/User.swift']);
expect(groups.get('__default__')).toEqual(['Loose/Orphan.swift']);
});
it('routes ALL files to __default__ when targets is null (no source dir found)', () => {
const files = ['Models/User.swift', 'Services/App.swift'];
const groups = groupSwiftFilesBySpmTarget(files, id, null);
expect([...groups.keys()]).toEqual(['__default__']);
expect(groups.get('__default__')).toEqual(files);
});
it('routes ALL files to __default__ when targets is empty', () => {
const files = ['Models/User.swift', 'Services/App.swift'];
const groups = groupSwiftFilesBySpmTarget(files, id, new Map());
expect([...groups.keys()]).toEqual(['__default__']);
expect(groups.get('__default__')).toEqual(files);
});
it('groups generic items via getPath (not just strings)', () => {
const items = [
{ filePath: 'Sources/Alpha/Core/User.swift', tag: 1 },
{ filePath: 'Sources/Beta/Core/User.swift', tag: 2 },
];
const targets = new Map([
['Alpha', 'Sources/Alpha'],
['Beta', 'Sources/Beta'],
]);
const groups = groupSwiftFilesBySpmTarget(items, (i) => i.filePath, targets);
expect(groups.get('Alpha')).toEqual([items[0]]);
expect(groups.get('Beta')).toEqual([items[1]]);
});
it('normalizes backslash paths to forward-slash before matching', () => {
const files = ['Sources\\Alpha\\Core\\User.swift'];
const targets = new Map([['Alpha', 'Sources/Alpha']]);
const groups = groupSwiftFilesBySpmTarget(files, id, targets);
expect(groups.get('Alpha')).toEqual(files);
});
});
describe('coerceSwiftTargets — duck-type the opaque resolutionConfig', () => {
it('returns the targets map from a SwiftPackageConfig-shaped object', () => {
const targets = new Map([['Alpha', 'Sources/Alpha']]);
expect(coerceSwiftTargets({ targets })).toBe(targets);
});
it('returns null for null / undefined / non-config values', () => {
expect(coerceSwiftTargets(null)).toBeNull();
expect(coerceSwiftTargets(undefined)).toBeNull();
expect(coerceSwiftTargets({})).toBeNull();
expect(coerceSwiftTargets({ targets: 'not-a-map' })).toBeNull();
expect(coerceSwiftTargets({ goModule: { modulePath: 'x' } })).toBeNull();
});
});