From 125e2293080dc8149bcaaf696162cf561119430d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:40:53 -0700 Subject: [PATCH] ts: a call through an annotation the parser cannot read stays unresolved (#536) tsc resolves a call through a variable's ANNOTATION. Where that annotation needs the checker -- an indexed access through a generic alias such as `StoreApi['setState']`, a named interface, an alias to an alias -- the parser fell through to the arrow initialiser and claimed it with `overloadCandidateCount = 1`, where tsc names one arm of an overload pair. The row is now left unresolved (count 0) instead. Unannotated arrows and annotations the parser can read (a function type, a type literal, one alias hop) resolve exactly as before, including the all-optional signature set that keeps the initialiser with the full candidate count. On a real state-management library: 9 call sites move from arrow/1 to unresolved, exactly the 9 that disagreed with getResolvedSignature; engine edges 4492 -> 4492. Goldens unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../extractors/ts-resolution-linker.ts | 8 ++ parser/src/test/typescript-tests.ts | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts index f08089ccd..e7bd514dc 100644 --- a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -544,6 +544,14 @@ export class TsLocalResolver { if (binding.kind !== TsBoundKind.VariableDeclaration) { return; } + // An annotation this pass cannot read -- an indexed access such as + // `StoreApi['setState']`, a named interface, an alias to an alias -- + // still decides the call for tsc, and it may declare an overload set. The + // initialiser is not what `getResolvedSignature` names, and `1` would be a + // count nobody measured (#536). Left unresolved for the engine. + if (declared.length === 0 && (binding.node as { type?: ts.TypeNode }).type) { + return; + } const variableHash = this.input.variableHashByNode.get(nodeId(binding.node, this.sf)); const variable = variableHash ? this.variableByHash.get(variableHash) : undefined; // THE arrow-function path, for an UNANNOTATED binding: `const f = () => {}; diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts index 2e7ca86aa..1cb810987 100644 --- a/parser/src/test/typescript-tests.ts +++ b/parser/src/test/typescript-tests.ts @@ -2944,6 +2944,83 @@ async function annotationCanDeclareASignatureSet(): Promise { } } +/** + * An annotation the parser cannot read is not a licence to name the initialiser (#536). + * + * tsc resolves a call through a variable's ANNOTATION. Where the annotation needs + * the checker -- an indexed access through a generic alias, a named interface -- + * the parser fell through to the arrow initialiser and claimed it with + * `overloadCandidateCount = 1`, where tsc sees an overload pair. The row now stays + * unresolved, which the engine measures instead of trusting. + */ +async function uncheckableAnnotationLeavesCallUnresolved(): Promise { + if (!parserPresent()) { + return pendingCheck('an uncheckable annotation leaves the call unresolved', + 'no extractor yet. An annotation may need the checker'); + } + const { outputDir, cleanup } = await analyseInline('ts-uncheckable-', { + 'store.ts': [ + 'type SetStateInternal = {', // 1 + ' _(partial: T | Partial, replace?: false): void;', // 2 + ' _(state: T, replace: true): void;', // 3 + "}['_'];", // 4 + 'export interface StoreApi { setState: SetStateInternal }', // 5 + ].join('\n'), + 'a.ts': [ + "import type { StoreApi } from './store';", // 1 + 'interface Twice { (a: string): void; (a: number): void }', // 2 + 'export function wire(state: S): void {', // 3 + " const fromTools: StoreApi['setState'] = (..._a: unknown[]): void => {};", // 4 + ' fromTools(state);', // 5 -> needs the checker + ' const twice: Twice = (_a: string | number): void => {};', // 6 + " twice('x');", // 7 -> needs the checker + ' const bare = (_a: string): void => {};', // 8 + " bare('x');", // 9 -> control: the arrow + ' const typed: (a: string) => void = (_a: string): void => {};', // 10 + " typed('x');", // 11 -> control: the annotation + '}', + ].join('\n'), + }); + try { + const methodLine = new Map(relation(outputDir, 'all-typescript-methods.csv') + .map((r) => [r['tsMethodUniqueHash'] ?? '', r['startLine'] ?? ''])); + const calls = relation(outputDir, 'all-typescript-call-sites.csv'); + const failures: string[] = []; + const expect = (line: string, targetLine: string, count: string, kind: string): void => { + const row = calls.find((r) => r['startLine'] === line); + if (row === undefined) { + failures.push(`no call site at line ${line}`); + return; + } + const got = methodLine.get(row['resolvedSignatureLinkHash'] ?? '') ?? ''; + if (got !== targetLine) { + failures.push(`call at line ${line} resolved to a method at line ${got}, ` + + `expected ${targetLine}`); + } + if ((row['overloadCandidateCount'] ?? '') !== count) { + failures.push(`call at line ${line} reports overloadCandidateCount ` + + `${row['overloadCandidateCount']}, expected ${count}`); + } + if ((row['resolvedTargetKind'] ?? '') !== kind) { + failures.push(`call at line ${line} reports resolvedTargetKind ` + + `${row['resolvedTargetKind']}, expected ${kind}`); + } + }; + // tsc names an arm of the annotation; the parser cannot, so it names nothing. + expect('5', '', '0', 'UNRESOLVED'); + expect('7', '', '0', 'UNRESOLVED'); + // Controls: an unannotated arrow and a function-type annotation still resolve. + expect('9', '8', '1', 'PROJECT_IMPLEMENTATION'); + expect('11', '10', '1', 'PROJECT_SIGNATURE'); + console.log(' 4 call(s): two through an annotation that needs the checker left unresolved, ' + + 'an unannotated arrow and a function-type annotation unchanged'); + for (const f of failures.slice(0, 8)) { console.log(` ${f}`); } + return failures.length ? 1 : 0; + } finally { + cleanup(); + } +} + /** * An object literal's property KEY reaches the IR (#111). @@ -4586,6 +4663,7 @@ const CHECKS: Check[] = [ { name: 'a decorator is descended once', proves: 'a callable inside a decorator argument is one ts_method, not two colliding on one key as a false overload set', run: decoratorDescendedOnce }, { name: 'declaration extensions are whole extensions', proves: '`.d.cts` and `.d.mts` are single extensions, so no stem keeps a stray `.d`', run: declarationExtensionsAreWholeExtensions }, { name: 'an annotation can declare a signature set', proves: 'a type literal holding several call signatures is resolved as the set it is, and arity picks the arm tsc picks', run: annotationCanDeclareASignatureSet }, + { name: 'an uncheckable annotation leaves the call unresolved', proves: 'a call through a variable whose annotation needs the checker names neither the initialiser nor a single candidate', run: uncheckableAnnotationLeavesCallUnresolved }, { name: 'object-literal keys reach the IR', proves: 'a property key is emitted as its own row, joinable to its value, never bound as a scope reference, and absent when computed', run: objectLiteralKeysReachTheIr }, { name: 'empty imports record their module edge', proves: 'an import that binds nothing still emits a resolved row, so a package imported only for its ambient declarations can be staged', run: emptyImportsRecordTheirModuleEdge }, { name: 'reopened type members share a group key', proves: 'a member of a declaration-merged type has one identity across files, so two signatures of it are overload siblings rather than a wrong answer', run: reopenedTypeMembersShareAGroupKey },