fix(swift): a call through a type path lands on the type it names (#2118)

`API.PackageController.GetRoute.query(on:)` kept only `query` — the
receiver was a navigation_expression, not an identifier — and the bare
name exact-matched whichever type's `query` came first. On
SwiftPackageIndex-Server every route type has one, so health checks and
package-controller tests "called" the dependency controller (or a test's
own `query`).

Extraction (TS and the kernel's swift walker, byte-parity): a receiver that
is a path of capitalized type names, two segments or more, not led by
`Self.`, is kept: `API.PackageController.GetRoute.query`. Instance chains
(`self.store.load()`) and mixed paths (`URLSession.shared.data`) are
unchanged.

Resolution (swift-type-visibility.ts, called decisively from
matchReferenceInner so nothing falls through to name guessing): the member
on the type the path names — a method, an enum case with associated values,
or a nested type's initializer. A member's owner path is its qualified name
with the outermost extension's written path read back from its line
(`extension API.PackageController { enum ShowRoute … }` →
`API::PackageController::ShowRoute`), so twin `Feature::State`s declared in
`extension BasicsView.Feature` and `extension ObservableBasicsView.Feature`
are told apart. A path shortened inside its namespace or behind a module
qualifier fits; two owners, or none, leave the call unresolved. TCA's
`@Reducer enum Path { case detail }` generates `Path.State`/`Path.Action`,
so `Path.State.detail(…)` lands on the written case.

A/B vs main (edge diffs, every change classified): SPI 76 retargeted, all to
the type the line names, 7 dropped (Fluent `App.Version.query`, `Plot.Node.*`
— SDK members main pinned on project methods); TCA 61 retargeted, 1 dropped
(`State.StateReducer.scope` → an unrelated `Store::scope`); mastodon-ios 177
retargeted (`Mastodon.API.V2.Instance.instance`, `L10n.Plural.Count.vote`),
11 SDK drops; swift-nio 6 calls into a deprecated alias became instantiates
of `ChannelOptions.Types.*`; IceCubes 1/1, Alamofire 2 SDK drops.
Kernel/wasm parity 0 diffs over 2,752 files on six repos.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 03:59:28 +00:00
committed by GitHub
parent e845b1cd10
commit b50c7c0a70
7 changed files with 314 additions and 2 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- A Swift call through a nested type, like `API.PackageController.GetRoute.query(on: db)`, now links to that type's method. Only `query` used to be kept, so the call linked to whichever type's `query` came first; in a Vapor app every route type has one, so callers, impact and `codegraph_explore` answers pointed at another endpoint's code. The same applies to nested types' initializers (`API.PackageController.Model(name:)`), to enum cases (`Gitlab.Error.requestFailed(status:)`), to types declared in an `extension API.PackageController { … }`, and to The Composable Architecture's `Path.State.detail(…)`. A call on a type path that names no type in the project, such as an SDK's `Plot.Node.footer(…)`, is left unlinked. Re-index Swift projects after upgrading.
- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading.
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
@@ -86,6 +86,13 @@ func freeFn(a: Int, cb: @escaping (Int) -> Void) -> Session? {
x?.optCall()
y!.forced()
Foo.make().draw()
API.PackageController.GetRoute.query(on: db)
API.PackageController
.GetRoute.query(on: db)
API.Model(name: "x")
Self.Inner.make()
App.lower.call()
API.Ünicode.query()
foo.bar().baz()
"lit".upper()
arr.map { $0.name }
+135
View File
@@ -0,0 +1,135 @@
/**
* A Swift call through a type path lands on the type the path names.
*
* `API.PackageController.GetRoute.query(on:)` kept only `query`, which
* exact-matched whichever type's `query` came first — in a Vapor app every
* route type has one, so a health check "called" the dependency controller's
* query. The path is now kept and resolved on its type: declared nested,
* in `extension API.PackageController { … }`, in `extension A.B.C { … }`,
* shortened inside its namespace, or module-qualified. A path that names no
* project type is left unresolved.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
const FILES: Record<string, string> = {
'Sources/App/API.swift': `enum API {}
extension API {
enum PackageController {
enum GetRoute {
static func query(on db: Int) -> Int { db }
static func query(on db: Int, limit: Int) -> Int { db + limit }
}
struct Model { let name: String }
}
}
extension API.PackageController {
enum ShowRoute {
static func query(on db: Int) -> Int { db }
}
}
extension API.DependencyController.GetRoute {
static func query(on db: Int) -> Int { db }
}
extension API {
enum DependencyController {
enum GetRoute {}
static func inside() -> Int { PackageController.GetRoute.query(on: 0) }
}
}
`,
'Sources/App/Other.swift': `enum Other { static func query(on db: Int) -> Int { db } }
enum Gitlab { enum Error: Swift.Error { case requestFailed(status: Int) } }
enum Remote { enum Error: Swift.Error { case requestFailed(status: Int) } }
struct Detail {}
enum Screens {
@Reducer
enum Path { case detail(Detail) }
}
enum Zed { enum GetRoute { static func query(on db: Int) -> Int { db } } }
enum Yon { enum GetRoute { static func query(on db: Int) -> Int { db } } }
`,
'Sources/App/Basics.swift': `struct BasicsView {}
struct ObservableBasicsView {}
extension BasicsView { struct Feature {} }
extension ObservableBasicsView { struct Feature {} }
extension BasicsView.Feature { struct State {} }
extension ObservableBasicsView.Feature { struct State {} }
`,
'Sources/App/Health.swift': `import Foundation
enum Health {
static func one() -> Int { API.PackageController.GetRoute.query(on: 1) }
static func two() -> Int {
API.PackageController.GetRoute
.query(on: 2)
}
static func three() -> Int { Other.query(on: 3) }
static func four() -> Int { API.PackageController.ShowRoute.query(on: 4) }
static func five() -> Int { API.DependencyController.GetRoute.query(on: 5) }
static func six() -> API.PackageController.Model { API.PackageController.Model(name: "x") }
static func seven() -> Int { App.Zed.GetRoute.query(on: 7) }
static func eight() -> Int { Missing.Thing.query(on: 8) }
static func nine() -> Int { API.PackageController.GetRoute.nothing(on: 9) }
static func ten() throws { throw Gitlab.Error.requestFailed(status: 10) }
static func eleven() { _ = Screens.Path.State.detail(Detail()) }
static func twelve() { _ = ObservableBasicsView.Feature.State() }
}
`,
};
describe('Swift: a call through a type path', () => {
it('lands on the type the path names, or nowhere', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-type-path-'));
roots.push(root);
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
const cg = await CodeGraph.init(root, { index: true });
try {
const reached = (caller: string): string[] => {
const from = cg.getNodesByName(caller).find((n) => n.kind === 'method');
expect(from, caller).toBeDefined();
return cg
.getOutgoingEdgesFrom([from!.id], ['calls', 'instantiates'])
.map((e) => cg.getNode(e.target))
.filter((n) => n && n.language === 'swift')
.map((n) => `${n!.qualifiedName}:${n!.startLine}`)
.sort();
};
// The first declared of GetRoute's two overloads.
expect(reached('one')).toEqual(['API::PackageController::GetRoute::query:5']);
expect(reached('two')).toEqual(['API::PackageController::GetRoute::query:5']);
expect(reached('three')).toEqual(['Other::query:1']);
// Declared in `extension API.PackageController { enum ShowRoute … }`.
expect(reached('four')).toEqual(['PackageController::ShowRoute::query:13']);
// Declared in `extension API.DependencyController.GetRoute { … }`.
expect(reached('five')).toEqual(['GetRoute::query:17']);
// A nested type's initializer.
expect(reached('six')).toEqual(['API::PackageController::Model:8']);
// A module qualifier.
expect(reached('seven')).toEqual(['Zed::GetRoute::query:9']);
// No such type, and no such member: nothing, not some other type's `query`.
expect(reached('eight')).toEqual([]);
expect(reached('nine')).toEqual([]);
// A case with associated values.
expect(reached('ten')).toEqual(['Gitlab::Error::requestFailed:2']);
// The case a TCA `@Reducer enum` generates its State from.
expect(reached('eleven')).toContain('Screens::Path::detail:7');
// Two types share the qualified name `Feature::State`; the extension each is declared in tells them apart.
expect(reached('twelve')).toEqual(['Feature::State:6']);
// Shortened inside its namespace.
expect(reached('inside')).toEqual(['API::PackageController::GetRoute::query:5']);
} finally {
cg.close();
}
});
});
+27 -1
View File
@@ -110,6 +110,24 @@ fn strip_js_ws(s: &str) -> String {
s.chars().filter(|c| !is_js_space(*c)).collect()
}
/// SWIFT_TYPE_PATH_RECEIVER (tree-sitter.ts): `API.PackageController.GetRoute` —
/// two segments or more, each an ASCII capital then `[A-Za-z0-9_]*` (JS `\w`),
/// not led by `Self.`.
fn is_type_path(s: &str) -> bool {
if s.starts_with("Self.") {
return false;
}
let mut segments = 0;
for seg in s.split('.') {
let b = seg.as_bytes();
if b.is_empty() || !b[0].is_ascii_uppercase() || !b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') {
return false;
}
segments += 1;
}
segments >= 2
}
struct Scope {
row: u32,
kind: &'static str,
@@ -1061,8 +1079,16 @@ impl<'t> Walker<'t> {
} else {
method_name.to_string()
};
} else if let Some(path) = receiver
.filter(|r| r.kind() == "navigation_expression")
.map(|r| strip_js_ws(self.text(r)))
.filter(|p| is_type_path(p))
{
// A type path, `API.PackageController.GetRoute.query(on:)`:
// keep it for the resolver (tree-sitter.ts SWIFT_TYPE_PATH_RECEIVER).
callee_name = format!("{path}.{method_name}");
} else {
// self_expression / super_expression / inner nav /
// self_expression / super_expression / instance nav /
// postfix / multi_line_string_literal → bare method name.
callee_name = method_name.to_string();
}
+17
View File
@@ -411,6 +411,8 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
/** The field of a `this.<field>.<method>()` receiver: public or ES private (#1496, #1987). */
const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']);
/** A Swift receiver that is a path of types, `API.PackageController.GetRoute` — two segments or more, each capitalized. */
const SWIFT_TYPE_PATH_RECEIVER = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+$/;
/**
* Identifier-rooted member chains have no inferred property type (#1566),
@@ -4902,6 +4904,21 @@ export class TreeSitterExtractor {
else reencode = !!innerCallee;
}
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
} else if (
this.language === 'swift' &&
receiver &&
receiver.type === 'navigation_expression' &&
SWIFT_TYPE_PATH_RECEIVER.test(getNodeText(receiver, this.source).replace(/\s+/g, ''))
) {
// Swift call through a type path — `API.PackageController.GetRoute.query(on:)`,
// on one line or split before the `.query`. Keep the path: the
// bare method name this used to emit exact-matched whichever
// type's `query` came first (every route in a Vapor app has one).
// The resolver finds the member on the type the path names, or
// leaves the call unresolved. An instance chain (`self.store.load()`,
// `viewModel.state.reset()`) is not a type path and stays bare.
// Mirrored in the kernel's extract_call (swift.rs).
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
} else if (
this.language === 'cfscript' &&
receiver &&
+8
View File
@@ -9,6 +9,7 @@ import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types';
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins';
import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility';
/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
* score. A name defined more times than this is "ubiquitous" — a method/symbol
@@ -4383,6 +4384,13 @@ function matchReferenceInner(
if (isUnresolvedJsMemberCall(ref)) return null;
// A Swift call through a type path (`API.PackageController.GetRoute.query`)
// resolves on the type the path names, or not at all: the strategies below
// would bind it by the member's name alone.
if (ref.language === 'swift' && ref.referenceKind === 'calls' && SWIFT_TYPE_PATH_CALL.test(ref.referenceName)) {
return nmTimed('swiftTypePath', ref, () => resolveSwiftTypePathCall(ref, context));
}
// Try strategies in order of confidence
let result: ResolvedRef | null;
+119 -1
View File
@@ -39,13 +39,14 @@ interface Memo {
conformances: Map<string, string[]>;
clauses: Map<string, string[]>;
inherited: Map<string, ReadonlySet<string>>;
owners: Map<string, string | null>;
}
const memos = new WeakMap<ResolutionContext, Memo>();
function memoFor(context: ResolutionContext): Memo {
let memo = memos.get(context);
if (!memo) {
memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map() };
memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map(), owners: new Map() };
memos.set(context, memo);
}
return memo;
@@ -340,3 +341,120 @@ export function gateSwiftTypeTarget(result: ResolvedRef | null, ref: UnresolvedR
if (!declared) return null;
return declared.id === result.targetNodeId ? result : { ...result, targetNodeId: declared.id };
}
/**
* A call through a type path, `API.PackageController.GetRoute.query` — at
* least two capitalized segments before the member (the extractor keeps the
* path only for those; see SWIFT_TYPE_PATH_RECEIVER in tree-sitter.ts).
*/
export const SWIFT_TYPE_PATH_CALL = /^(?!Self\.)[A-Z]\w*(?:\.[A-Z]\w*)+\.[A-Za-z_]\w*$/;
/** What a type-path call can name: a method, or a case with associated values (`Gitlab.Error.requestFailed(status:)`). */
const PATH_MEMBER_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>(['method', 'function', 'enum_member']);
/**
* `API.PackageController.GetRoute.query(on:)` lands on the `query` of the
* type the path names, and `API.PackageController.Model(name:)` on that
* nested type — never on the member's name alone: in a Vapor app every
* route's type has a `query`. A path the call's own namespace lets it
* shorten (`PackageController.GetRoute` inside `extension API`) or a module
* qualifier (`Vapor.HTTPStatus`) still fits. No type on the path, or two,
* leaves the call unresolved.
*/
export function resolveSwiftTypePathCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const name = ref.referenceName;
const dot = name.lastIndexOf('.');
const member = name.slice(dot + 1);
const path = name.slice(0, dot).split('.').join('::');
const onPath = (kinds: ReadonlySet<Node['kind']>): Node[] => {
const exact: Node[] = [];
const shortened: Node[] = [];
for (const n of context.getNodesByName(member)) {
if (n.language !== 'swift' || !kinds.has(n.kind)) continue;
if (TYPE_KINDS.has(n.kind) && isSwiftExtension(n, context)) continue;
const owner = ownerPath(n, context);
if (owner === null) continue;
if (owner === path) exact.push(n);
else if (owner.endsWith(`::${path}`) || moduleQualified(owner, path, context)) shortened.push(n);
}
return exact.length > 0 ? exact : shortened;
};
// `API.PackageController.Model(name:)` constructs a nested type.
let fit = /^[A-Z]/.test(member) ? onPath(TYPE_KINDS) : [];
if (fit.length === 0) fit = onPath(PATH_MEMBER_KINDS);
// The Composable Architecture's `@Reducer enum Path { case detail(Detail) }`
// generates `Path.State` and `Path.Action` with the same cases, so
// `Path.State.detail(…)` constructs the case written in `Path`.
if (fit.length === 0) {
const reducer = /^(.+)::(?:State|Action)$/.exec(path)?.[1];
if (reducer) {
fit = context.getNodesByName(member).filter((n) => {
if (n.language !== 'swift' || n.kind !== 'enum_member') return false;
const owner = ownerPath(n, context);
return owner !== null && (owner === reducer || owner.endsWith(`::${reducer}`)) && isReducerEnum(n, context);
});
}
}
if (fit.length === 0 || new Set(fit.map((n) => ownerPath(n, context))).size > 1) return null;
// Of one type's overloads, the first declared.
const target = fit.reduce((a, b) => (a.filePath < b.filePath || (a.filePath === b.filePath && a.startLine <= b.startLine) ? a : b));
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' };
}
/** Is this case declared in a `@Reducer enum`? */
function isReducerEnum(member: Node, context: ResolutionContext): boolean {
const owner = context
.getNodesInFile(member.filePath)
.filter((t) => t.kind === 'enum' && t.startLine <= member.startLine && t.endLine >= member.endLine)
.reduce<Node | null>((inner, t) => (!inner || t.startLine >= inner.startLine ? t : inner), null);
if (!owner) return false;
const lines = linesOf(owner.filePath, context) ?? [];
// The node starts at its attributes; `@Reducer` may also sit on the line above.
return /@Reducer\b/.test(lines.slice(Math.max(0, owner.startLine - 2), owner.startLine + 1).join(' '));
}
/** `Vapor::HTTPStatus` for a type `HTTPStatus`: a qualifier that names no project type is a module. */
function moduleQualified(owner: string, path: string, context: ResolutionContext): boolean {
if (!path.endsWith(`::${owner}`)) return false;
const head = path.slice(0, path.length - owner.length - 2);
return !head.includes('::') && !context.getNodesByName(head).some((n) => n.language === 'swift' && TYPE_KINDS.has(n.kind));
}
/**
* The full path of the type a member is declared on. Its qualified name
* starts at the outermost declaration in its file, and an extension is named
* by its last segment — the members of `extension API.PackageController {
* enum GetRoute { … } }` are `PackageController::GetRoute::…` — so the
* outermost extension's written path is read back from its line. Null for a
* top-level function.
*/
function ownerPath(member: Node, context: ResolutionContext): string | null {
const memo = memoFor(context).owners;
const hit = memo.get(member.id);
if (hit !== undefined) return hit;
const cut = member.qualifiedName.lastIndexOf('::');
let path: string | null = cut < 0 ? null : member.qualifiedName.slice(0, cut);
if (path !== null) {
const first = path.split('::')[0]!;
const outer = context
.getNodesInFile(member.filePath)
.filter((t) => t.name === first && TYPE_KINDS.has(t.kind) && t.startLine <= member.startLine && t.endLine >= member.endLine)
.reduce<Node | null>((out, t) => (!out || t.startLine < out.startLine ? t : out), null);
const extended = outer ? extendedPath(outer, context) : null;
if (extended) path = extended + path.slice(first.length);
}
memo.set(member.id, path);
return path;
}
/** `extension API.PackageController {` → `API::PackageController`; null for anything but an extension of a nested type. */
function extendedPath(node: Node, context: ResolutionContext): string | null {
if (!isSwiftExtension(node, context)) return null;
const lines = linesOf(node.filePath, context);
if (!lines) return null;
const head = [(lines[node.startLine - 1] ?? '').slice(node.startColumn), ...lines.slice(node.startLine, node.startLine + 3)]
.join(' ')
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
const written = /\bextension\s+((?:[A-Za-z_]\w*\s*\.\s*)+[A-Za-z_]\w*)/.exec(head)?.[1];
return written ? written.replace(/\s+/g, '').split('.').join('::') : null;
}