GitNexus/gitnexus/test/unit/scope-resolution/pick-unique-global-class.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

190 lines
8.7 KiB
TypeScript

/**
* Unit tests for `pickUniqueGlobalClass` + `buildGlobalClassIndex` — the
* constructor-form global class fallback in `free-call-fallback.ts`.
*
* U5 (Swift remediation, 2026-05-31) replaced a per-call-site
* `scopes.defs.byId.values()` rescan with a once-built `simpleName ->
* class-like defs` index, making each constructor-form fallback site O(1)
* instead of O(|defs|). The refactor is behavior-PRESERVING for all 8
* `allowGlobalFreeCallFallback` languages (c, cpp, go, javascript, php, ruby,
* rust, swift) — the only observable change is performance.
*
* These tests exercise the helpers via synthetic `SymbolDefinition` stubs — no
* fixtures, no pipeline — mirroring the `pick-implicit-this-overload.test.ts`
* precedent. They assert:
* - the resolution contract (unique / same-qualifiedName-keep-first /
* distinct-qualifiedName-ambiguous);
* - the `Class | Struct | Interface` kind filter, including KEEP-`Interface`
* (KTD5 — a future drop of `Interface` is a deliberate test-breaking
* change, not an accident);
* - equivalence with a reference linear scan, which guards the O(n)->O(1)
* ordering invariant the refactor relies on.
*/
import { describe, it, expect } from 'vitest';
import type { SymbolDefinition } from 'gitnexus-shared';
import {
buildGlobalClassIndex,
pickUniqueGlobalClass,
} from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
filePath: 'x.swift',
type: 'Class',
qualifiedName: overrides.nodeId,
...overrides,
});
/** Wrap a flat def list as the `scopes.defs.byId` map `buildGlobalClassIndex`
* iterates. Insertion order is preserved by `Map`, so this reproduces the
* `defs.byId.values()` iteration order the old per-site scan walked. */
const mkScopes = (defs: readonly SymbolDefinition[]): ScopeResolutionIndexes =>
({
defs: {
byId: new Map(defs.map((d) => [d.nodeId, d])),
},
}) as unknown as ScopeResolutionIndexes;
/** Reference O(|defs|) linear scan — the pre-U5 `pickUniqueGlobalClass` body,
* kept verbatim so the equivalence test can prove the index path matches it
* for every scenario. */
const referenceLinearScan = (
name: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined => {
let found: SymbolDefinition | undefined;
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qualified = def.qualifiedName;
if (qualified === undefined || qualified.length === 0) continue;
const dot = qualified.lastIndexOf('.');
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
if (simple !== name) continue;
if (found !== undefined && found.qualifiedName !== def.qualifiedName) return undefined;
if (found === undefined) found = def;
}
return found;
};
const resolve = (name: string, defs: readonly SymbolDefinition[]): SymbolDefinition | undefined =>
pickUniqueGlobalClass(name, buildGlobalClassIndex(mkScopes(defs)));
describe('pickUniqueGlobalClass — once-built global class index (U5)', () => {
it('returns the def for a unique simple-name match', () => {
const user = mkDef({ nodeId: 'def:User', qualifiedName: 'app.User' });
const result = resolve('User', [user]);
expect(result?.nodeId).toBe('def:User');
});
it('keeps the first fragment when matches share one qualifiedName (extension / partial)', () => {
// Same logical type re-keyed across extension / partial-class fragments —
// they resolve to the same graph node, so this is NOT ambiguous.
const main = mkDef({ nodeId: 'def:User#1', qualifiedName: 'app.User' });
const ext = mkDef({ nodeId: 'def:User#2', qualifiedName: 'app.User' });
const result = resolve('User', [main, ext]);
expect(result?.nodeId).toBe('def:User#1');
});
it('returns undefined for a distinct-qualifiedName collision (ambiguous)', () => {
// Two genuinely distinct types sharing a simple name → unresolved rather
// than guessing.
const a = mkDef({ nodeId: 'def:a.User', qualifiedName: 'a.User' });
const b = mkDef({ nodeId: 'def:b.User', qualifiedName: 'b.User' });
const result = resolve('User', [a, b]);
expect(result).toBeUndefined();
});
it('does not index non-class-like kinds (Function/Method/Constructor/Enum/Record)', () => {
const defs: SymbolDefinition[] = [
mkDef({ nodeId: 'def:fn', type: 'Function', qualifiedName: 'app.Foo' }),
mkDef({ nodeId: 'def:m', type: 'Method', qualifiedName: 'app.Foo' }),
mkDef({ nodeId: 'def:ctor', type: 'Constructor', qualifiedName: 'app.Foo' }),
mkDef({ nodeId: 'def:enum', type: 'Enum', qualifiedName: 'app.Foo' }),
mkDef({ nodeId: 'def:rec', type: 'Record', qualifiedName: 'app.Foo' }),
];
const index = buildGlobalClassIndex(mkScopes(defs));
expect(index.has('Foo')).toBe(false);
expect(pickUniqueGlobalClass('Foo', index)).toBeUndefined();
});
it('resolves a Struct match', () => {
const point = mkDef({ nodeId: 'def:Point', type: 'Struct', qualifiedName: 'geo.Point' });
expect(resolve('Point', [point])?.nodeId).toBe('def:Point');
});
it('resolves an Interface match (locks in KEEP-Interface — KTD5)', () => {
// KTD5: Interface stays in the filter for the behavior-preserving 8-lang
// refactor. A future protocol-exclusion change must break THIS test
// deliberately rather than silently.
const proto = mkDef({
nodeId: 'def:Drawable',
type: 'Interface',
qualifiedName: 'ui.Drawable',
});
expect(resolve('Drawable', [proto])?.nodeId).toBe('def:Drawable');
});
it('skips defs with empty or undefined qualifiedName', () => {
const defs: SymbolDefinition[] = [
mkDef({ nodeId: 'def:empty', qualifiedName: '' }),
mkDef({ nodeId: 'def:undef', qualifiedName: undefined }),
];
const index = buildGlobalClassIndex(mkScopes(defs));
expect(index.size).toBe(0);
expect(pickUniqueGlobalClass('', index)).toBeUndefined();
});
it('returns undefined for a missing-name lookup', () => {
const user = mkDef({ nodeId: 'def:User', qualifiedName: 'app.User' });
expect(resolve('Nonexistent', [user])).toBeUndefined();
});
it('keys by the last dotted segment, not the full qualifiedName', () => {
// Deeply-qualified name — lookup is by simple name only.
const deep = mkDef({ nodeId: 'def:deep', qualifiedName: 'a.b.c.Widget' });
expect(resolve('Widget', [deep])?.nodeId).toBe('def:deep');
expect(resolve('a.b.c.Widget', [deep])).toBeUndefined();
});
it('matches an undotted qualifiedName by its whole value', () => {
const bare = mkDef({ nodeId: 'def:Bare', qualifiedName: 'Bare' });
expect(resolve('Bare', [bare])?.nodeId).toBe('def:Bare');
});
it('equivalence: index result == reference linear scan for every scenario', () => {
// One combined corpus mixing class-like kinds, excluded kinds, same- and
// distinct-qualifiedName collisions, and skipped defs — exercised across a
// battery of names. The index path must agree with the linear scan on
// BOTH the resolved nodeId and the ambiguous/miss (undefined) outcome,
// which is what guards the O(n)->O(1) ordering invariant.
const corpus: SymbolDefinition[] = [
mkDef({ nodeId: 'def:User#1', qualifiedName: 'app.User' }),
mkDef({ nodeId: 'def:fn', type: 'Function', qualifiedName: 'app.User' }),
mkDef({ nodeId: 'def:User#2', qualifiedName: 'app.User' }), // same-qn fragment
mkDef({ nodeId: 'def:a.Item', qualifiedName: 'a.Item' }),
mkDef({ nodeId: 'def:b.Item', qualifiedName: 'b.Item' }), // distinct-qn collision
mkDef({ nodeId: 'def:Point', type: 'Struct', qualifiedName: 'geo.Point' }),
mkDef({ nodeId: 'def:Drawable', type: 'Interface', qualifiedName: 'ui.Drawable' }),
mkDef({ nodeId: 'def:Enumish', type: 'Enum', qualifiedName: 'app.Enumish' }),
mkDef({ nodeId: 'def:empty', qualifiedName: '' }),
mkDef({ nodeId: 'def:undef', qualifiedName: undefined }),
];
const scopes = mkScopes(corpus);
const index = buildGlobalClassIndex(scopes);
const names = [
'User', // same-qn keep-first
'Item', // distinct-qn ambiguous
'Point', // struct
'Drawable', // interface
'Enumish', // excluded kind → miss
'Missing', // miss
'', // skipped-qn → miss
];
for (const name of names) {
const fromIndex = pickUniqueGlobalClass(name, index);
const fromScan = referenceLinearScan(name, scopes);
expect(fromIndex?.nodeId).toBe(fromScan?.nodeId);
}
});
});