fix(code-knowledge): keep a call's own name from counting as a shadow

Two follow-ups from review.

The closed rule collected every identifier in the enclosing scopes, and that
included the callee of a sibling call: in `func run() { work(); work() }` each
call read the other's `work` as evidence, so both cross-file resolutions were
suppressed. The name a call goes through is a use, not a binding. Arguments are
still walked, so a closure that does bind a name keeps counting.

`SWIFT_IDENTIFIER` also admitted ASCII only, so a parameter named `π` was
dropped from the set and a sibling `func π()` won the fallback. Swift
identifiers are not ASCII.

Both shapes now have cases; all four mutations the rule has to survive are
killed by the suite.
This commit is contained in:
Smilewithoutfalling
2026-09-29 19:43:03 +08:00
parent d62a454045
commit 891da0826b
2 changed files with 42 additions and 1 deletions
@@ -485,4 +485,29 @@ describe('Swift module-scope resolution yields to enclosing bindings', () => {
// parameter list is empty, which is all the old collector inspected.
expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0);
});
it('does not let one call stand in for another in the same body', async () => {
const { result } = await extractFiles([
['Sources/App/Worker.swift', 'func work() -> Int { return 1 }\n'],
['Sources/App/Twice.swift', 'func run() -> Int {\n work()\n work()\n return 0\n}\n'],
]);
// Neither call binds `work`; each one only *uses* it. Counting the other
// call's identifier as evidence would suppress both, so the over-collection
// this rule accepts has a floor: a call's own name sits below it.
const references = result.edges.filter((e) => e.relation === 'REFERENCES');
expect(references).toHaveLength(2);
for (const reference of references) expect(reference.to).toBe('Sources/App/Worker.swift');
});
it('recognises a binding whose name is not ASCII', async () => {
const { result } = await extractFiles([
['Sources/App/Pi.swift', 'func π() -> Int { return 1 }\n'],
['Sources/App/Runner.swift', 'func run(π: () -> Int) -> Int {\n return π()\n}\n'],
]);
// Swift identifiers are not ASCII. A class that only admits [A-Za-z] drops
// the parameter and lets the sibling `func π()` win the fallback.
expect(result.edges.filter((e) => e.relation === 'REFERENCES')).toHaveLength(0);
});
});
+17 -1
View File
@@ -215,7 +215,9 @@ function namedChildrenOf(node: Node): Node[] {
return node.namedChildren.filter((child): child is Node => child !== null);
}
const SWIFT_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
// Swift identifiers admit any Unicode letter, so an ASCII-only class would drop
// a parameter named `π` and let a sibling `func π()` win the fallback.
const SWIFT_IDENTIFIER = /^[\p{L}_][\p{L}\p{N}_]*$/u;
function addSwiftName(name: string, names: Set<string>): void {
// `_` is the "no internal name" placeholder, not a binding.
@@ -278,6 +280,20 @@ function swiftLocalBindingsAt(node: Node): string[] {
if (current.type === "user_type") {
return;
}
// The name a *call* goes through is a use, not a binding. Without this,
// `work(); work()` has each call count the other one's identifier as
// evidence, and both cross-file resolutions are suppressed. Arguments are
// still walked, so a closure that does bind a name — `handler { work in
// work() }` — keeps counting.
if (current.type === "call_expression") {
for (const child of namedChildrenOf(current)) {
if (child.type === "navigation_expression" || child.type === "simple_identifier") {
continue;
}
collect(child);
}
return;
}
for (const child of namedChildrenOf(current)) {
collect(child);
}