From 48c07899960d9c4c3aa0319831e91b63046eeb21 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:07 -0700 Subject: [PATCH] typescript: read strictBindCallApply off the call site's module In a project whose tsconfigs disagree on strictBindCallApply, every call/apply/bind on a callable receiver answered both Function's and CallableFunction's declaration, and a site in a loose program named the strict one. The flag now comes from the tsconfig governing the module the call is written in: a strict program's call answers CallableFunction, a loose program's answers Function. A module no tsconfig claims keeps the project-wide hedge, and a receiver that only reaches Function's member keeps it under any flag. The case oracle compiles each nested tsconfig as its own program, so a case can hold a strict and a loose program side by side. Case 82: oracle 10, engine 16 -> 10, extra 6 -> 0. Controls: cases 21, 22, 79 and the own-bind sites unchanged; 8 real apps (uniform regime) give identical edges before and after. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../82-mixed-bind-call-apply/src/factory.ts | 28 +++ .../82-mixed-bind-call-apply/src/globals.d.ts | 66 ++++++++ .../src/loose/loose-site.ts | 20 +++ .../src/loose/tsconfig.json | 8 + .../src/strict-site.ts | 19 +++ .../src/tsconfig.json | 7 + .../expected/82-mixed-bind-call-apply.edges | 14 ++ .../expected/82-mixed-bind-call-apply.entries | 8 + .../82-mixed-bind-call-apply.envelope | 4 + .../expected/82-mixed-bind-call-apply.fields | 0 .../82-mixed-bind-call-apply.fields-oracle | 7 + .../expected/82-mixed-bind-call-apply.oracle | 1 + .../82-mixed-bind-call-apply.type-use | 7 + .../82-mixed-bind-call-apply.types-oracle | 7 + .../typescript/ground-truth/tsc-program.mjs | 56 +++--- .../test/typescript/tools/tsc_oracle_case.mjs | 159 ++++++++++-------- .../engine/expression-resolution/overload.dl | 27 +++ .../typescript/engine/projections/modules.dl | 11 ++ graph/typescript/souffle/decls_all.dl | 2 + 19 files changed, 361 insertions(+), 90 deletions(-) create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts create mode 100644 graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.edges create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.entries create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.envelope create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.fields create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.oracle create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.type-use create mode 100644 graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts new file mode 100644 index 000000000..0b9ab9520 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/factory.ts @@ -0,0 +1,28 @@ +// ============================================================================ +// CASE 82 — `call` / `apply` / `bind` in a project whose tsconfigs DISAGREE +// ============================================================================ +// Cases 21 and 22 each compile under ONE setting of `strictBindCallApply`. This one +// has two programs: `src/tsconfig.json` (strict) governs this file and `strict-site.ts`, +// and `src/loose/tsconfig.json` (strictBindCallApply: false) governs `loose/`. The +// same call written in each answers with a different declaration: +// +// strict-site.ts -> CallableFunction#bind / #call / #apply +// loose/loose-site.ts -> Function#bind / #call / #apply +// +// The flag is read off the module the CALL is written in, never off the module that +// declares `bind` (the library) and never as one answer for the whole project. +// +// The receivers are TYPED through a method's return, the shape that made the regime +// decide at all: `factory.get(k).bind(r)`. + +export type Handler = (req: string) => string; + +export class HandlerFactory { + get(key: string): Handler { + return (req: string) => key + req; + } +} + +export function plain(value: string): string { + return value; +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts new file mode 100644 index 000000000..850c0f895 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/globals.d.ts @@ -0,0 +1,66 @@ +// ============================================================================ +// The case's OWN standard library — the minimum the checker needs under `noLib`. +// ============================================================================ +// WHY noLib. This mechanism's targets are declared in the REAL `lib.es5.d.ts`, and a +// case never has it: the case oracle only records a target inside the case's own files, +// and the engine is handed only `--library `. So with the real library +// neither side can name the target and the case measures nothing. Declaring `Function` +// and `CallableFunction` HERE puts them on both sides of the comparison. +// +// This is not a mock of the behaviour. The checker resolves `CallableFunction` by NAME +// from whatever is in scope and then applies `strictBindCallApply` itself, so these +// declarations exercise the real decision. Verified: the same two declarations answer +// CallableFunction under `strict: true` and Function under +// `strict: false, strictBindCallApply: false`. +// +// Everything other than Function/CallableFunction is here only because `noLib` removes +// it and the checker requires it. Keep this file minimal: an interface added here is a +// declaration the engine can resolve to, and an accidental one is an accidental answer. + +interface Object { toString(): string; } +interface Boolean {} +interface Number {} +interface String { padStart(width: number): string; padEnd(width: number): string; trim(): string; } +interface Array { length: number; } +interface ReadonlyArray { length: number; } +interface IArguments {} +interface RegExp {} + +// ── the two declarations the flag chooses between ─────────────────────────── +// Mirrors lib.es5.d.ts: `Function` declares all three, and `CallableFunction extends +// Function` REDECLARES the same three with precise generic signatures. Both are in +// scope at every site; only the flag decides. +// NO explicit `this` parameter on these. lib.es5.d.ts declares them with one, but the +// case oracle's edge label includes a `this` parameter in the signature while the engine +// (correctly) does not treat it as a parameter at all — so `this: Function` renders as +// `Function#apply(Function,any,any)` on one side and `Function#apply(any,any)` on the +// other, and every site scores as a disagreement the engine did not actually make. +interface Function { + apply(thisArg: any, argArray?: any): any; + call(thisArg: any, ...argArray: any[]): any; + bind(thisArg: any, ...argArray: any[]): any; + readonly name: string; + toString(): string; +} + +interface CallableFunction extends Function { + // Simply typed on purpose. lib.es5.d.ts gives these precise generic signatures + // (`this: (this: T, ...args: A) => R`), and this fixture does not need them: what it + // tests is that TWO declarations of the same member names exist and that + // `strictBindCallApply` picks between them, which the checker decides from the flag + // and not from how well a signature fits. A function-typed parameter also renders + // badly in the case oracle's edge labels — the label splits on the commas inside + // `(this: T, ...args: A) => R` and emits `apply(args: A) =,T,T)`, which can never + // match the engine's label, so every site scored as a disagreement. + // + // Arguments are optional and returns are `any` so every call form in the case source + // typechecks. The two declarations stay distinguishable without help: an edge label + // carries the OWNER, so `CallableFunction#call` and `Function#call` never collide, and + // that owner is exactly what the flag decides. + call(thisArg: any, arg0?: any, arg1?: any): any; + apply(thisArg: any, args?: any): any; + bind(thisArg: any, arg0?: any): any; +} + + +interface NewableFunction extends Function {} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts new file mode 100644 index 000000000..ab5f80d39 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/loose-site.ts @@ -0,0 +1,20 @@ +// Governed by src/loose/tsconfig.json (strictBindCallApply: false) -> Function's +// declarations, although the project's other program is strict. +import { HandlerFactory, plain } from '../factory'; + +export function looseSites(factory: HandlerFactory, r: object): string { + const bound = factory.get('a').bind(r); + factory.get('b').call(r, 'x'); + plain.apply(null, ['y']); + return bound('z'); +} + +// CONTROL — a type that declares its own `bind` keeps it under either regime. +interface OwnBindLoose { + (x: number): number; + bind(label: string): string; +} + +export function ownBindLoose(ob: OwnBindLoose): string { + return ob.bind('own'); +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json new file mode 100644 index 000000000..33a61be10 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/loose/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": false, + "strictBindCallApply": false, + "noLib": true + }, + "include": ["**/*", "../globals.d.ts"] +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts new file mode 100644 index 000000000..6776b405f --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/strict-site.ts @@ -0,0 +1,19 @@ +// Governed by src/tsconfig.json (strict) -> CallableFunction's declarations. +import { HandlerFactory, plain } from './factory'; + +export function strictSites(factory: HandlerFactory, r: object): string { + const bound = factory.get('a').bind(r); + factory.get('b').call(r, 'x'); + plain.apply(null, ['y']); + return bound('z'); +} + +// CONTROL — a type that declares its own `bind` keeps it under either regime. +interface OwnBind { + (x: number): number; + bind(label: string): string; +} + +export function ownBindStrict(ob: OwnBind): string { + return ob.bind('own'); +} diff --git a/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json new file mode 100644 index 000000000..f901b84b7 --- /dev/null +++ b/graph/test/typescript/cases/82-mixed-bind-call-apply/src/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true, + "noLib": true + }, + "exclude": ["loose"] +} diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.edges b/graph/test/typescript/expected/82-mixed-bind-call-apply.edges new file mode 100644 index 000000000..2c0fac843 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.edges @@ -0,0 +1,14 @@ +ambiguous_unknown FUNCTION_CALL loose/loose-site#looseSites(HandlerFactory,object) @L9 -> - +ambiguous_unknown FUNCTION_CALL strict-site#strictSites(HandlerFactory,object) @L8 -> - +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L6 -> Function#bind(any,any[]) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L6 -> HandlerFactory#get(string) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L7 -> Function#call(any,any[]) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L7 -> HandlerFactory#get(string) +known_edge METHOD_CALL loose/loose-site#looseSites(HandlerFactory,object) @L8 -> Function#apply(any,any) +known_edge METHOD_CALL loose/loose-site#ownBindLoose(OwnBindLoose) @L19 -> OwnBindLoose#bind(string) +known_edge METHOD_CALL strict-site#ownBindStrict(OwnBind) @L18 -> OwnBind#bind(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L5 -> CallableFunction#bind(any,any) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L5 -> HandlerFactory#get(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L6 -> CallableFunction#call(any,any,any) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L6 -> HandlerFactory#get(string) +known_edge METHOD_CALL strict-site#strictSites(HandlerFactory,object) @L7 -> CallableFunction#apply(any,any) diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.entries b/graph/test/typescript/expected/82-mixed-bind-call-apply.entries new file mode 100644 index 000000000..ff9be6b19 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.entries @@ -0,0 +1,8 @@ +── entry_point (7) ── + exported_from_entry_module loose/loose-site#looseSites loose-site.ts:5 + exported_from_entry_module loose/loose-site#ownBindLoose loose-site.ts:18 + exported_from_entry_module strict-site#ownBindStrict strict-site.ts:17 + exported_from_entry_module strict-site#strictSites strict-site.ts:4 + unimported_module globals# globals.d.ts:1 + unimported_module loose/loose-site# loose-site.ts:1 + unimported_module strict-site# strict-site.ts:1 diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope b/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope new file mode 100644 index 000000000..65b5ab003 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.envelope @@ -0,0 +1,4 @@ +nominal globals#Function.apply -> globals#CallableFunction.apply +nominal globals#Function.bind -> globals#CallableFunction.bind +nominal globals#Function.call -> globals#CallableFunction.call +value factory#@18:23 -> factory#HandlerFactory. diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.fields b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle new file mode 100644 index 000000000..7431a6893 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.fields-oracle @@ -0,0 +1,7 @@ +82-mixed-bind-call-apply [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.oracle new file mode 100644 index 000000000..f31a2b293 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.oracle @@ -0,0 +1 @@ +oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.type-use b/graph/test/typescript/expected/82-mixed-bind-call-apply.type-use new file mode 100644 index 000000000..6eaa852a4 --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.type-use @@ -0,0 +1,7 @@ +known_edge METHOD_PARAM 0 loose/loose-site [METHOD_PARAM] -> HandlerFactory +known_edge METHOD_PARAM 0 loose/loose-site [METHOD_PARAM] -> OwnBindLoose +known_edge METHOD_PARAM 0 strict-site [METHOD_PARAM] -> HandlerFactory +known_edge METHOD_PARAM 0 strict-site [METHOD_PARAM] -> OwnBind +known_edge METHOD_RETURN 0 HandlerFactory [METHOD] -> Handler +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function diff --git a/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle b/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle new file mode 100644 index 000000000..255041bac --- /dev/null +++ b/graph/test/typescript/expected/82-mixed-bind-call-apply.types-oracle @@ -0,0 +1,7 @@ +82-mixed-bind-call-apply [types] + precision 1.0000 (7 correct, 0 wrong) + recall 1.0000 (7 of 7 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + contexts METHOD_PARAM=4 METHOD_RETURN=1 SUPER_TYPE=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index bcf4b5616..ef0341117 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -7,7 +7,7 @@ * them is meaningless. Copying ninety lines of labelling is how they would drift apart. * The existing per-case goldens prove the lift changed nothing. * - * loadProgram(srcDir, libDir, toolName) + * loadProgram(srcDir, libDir, toolName[, programDir]) * -> { ts, program, checker, own, reachable, root, libRoot, * moduleName, simple, labelOf, callerOf, diagnostics } * @@ -18,7 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadTypeScript } from './load-typescript.mjs'; -export function loadProgram(srcDir, libDir, toolName) { +export function loadProgram(srcDir, libDir, toolName, programDir) { const root = path.resolve(srcDir); // Through the shared loader like the rest of the stack: a bare `import ts from // 'typescript'` resolves to whatever is nearest and dies on a property access if that @@ -70,29 +70,39 @@ export function loadProgram(srcDir, libDir, toolName) { // The PARSER already reads the case's tsconfig (it must, to emit the resolved flag at // ts_module c27), so honouring it here is what makes the two sides describe the same // program. - const caseConfig = path.join(root, 'tsconfig.json'); - if (fs.existsSync(caseConfig)) { - const read = ts.readConfigFile(caseConfig, ts.sys.readFile); - if (read.error) { - console.error(`case tsconfig is unreadable: ${caseConfig}`); - process.exit(1); - } - const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root); - if (parsed.errors.length) { - console.error(`case tsconfig is invalid: ${caseConfig}`); - for (const e of parsed.errors) { - console.error(` ${ts.flattenDiagnosticMessageText(e.messageText, ' ')}`); + // A NESTED tsconfig (`programDir`) is a second program in the same case, as the parser + // treats it: its options are merged over the case root's, so one case can hold a strict + // program and a loose one side by side (#416). The caller picks, per file, the program + // whose directory governs it. + const configDirs = [root]; + if (programDir !== undefined && path.resolve(programDir) !== root) { + configDirs.push(path.resolve(programDir)); + } + for (const configDir of configDirs) { + const caseConfig = path.join(configDir, 'tsconfig.json'); + if (fs.existsSync(caseConfig)) { + const read = ts.readConfigFile(caseConfig, ts.sys.readFile); + if (read.error) { + console.error(`case tsconfig is unreadable: ${caseConfig}`); + process.exit(1); + } + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, configDir); + if (parsed.errors.length) { + console.error(`case tsconfig is invalid: ${caseConfig}`); + for (const e of parsed.errors) { + console.error(` ${ts.flattenDiagnosticMessageText(e.messageText, ' ')}`); + } + process.exit(1); } - process.exit(1); + // Merged, not replaced: a case states the ONE option it is about and inherits the + // rest, so a case tsconfig cannot silently drop `lib` and change every other answer. + Object.assign(options, parsed.options); + // `noLib` and the default `lib` list contradict each other, and the default is ours, + // not the case's — so a case asking for noLib gets it rather than getting both. + if (options.noLib) delete options.lib; + // `files`/`include` are ignored on purpose — the file set is the directory walk above, + // which is what the parser is handed too. } - // Merged, not replaced: a case states the ONE option it is about and inherits the - // rest, so a case tsconfig cannot silently drop `lib` and change every other answer. - Object.assign(options, parsed.options); - // `noLib` and the default `lib` list contradict each other, and the default is ours, - // not the case's — so a case asking for noLib gets it rather than getting both. - if (options.noLib) delete options.lib; - // `files`/`include` are ignored on purpose — the file set is the directory walk above, - // which is what the parser is handed too. } const program = ts.createProgram(files, options); diff --git a/graph/test/typescript/tools/tsc_oracle_case.mjs b/graph/test/typescript/tools/tsc_oracle_case.mjs index 681e8f486..21b0783a4 100644 --- a/graph/test/typescript/tools/tsc_oracle_case.mjs +++ b/graph/test/typescript/tools/tsc_oracle_case.mjs @@ -18,83 +18,108 @@ * * usage: node tsc_oracle_case.mjs [lib-dir] */ +import fs from 'node:fs'; import path from 'node:path'; import { loadProgram } from '../ground-truth/tsc-program.mjs'; -const { - ts, program, checker, own, reachable, labelOf, callerOf, diagnostics, - implicitCtorOwner, labelOfImplicitCtor, -} = loadProgram(process.argv[2], process.argv[3], 'tsc_oracle_case'); +// ONE PROGRAM PER tsconfig. A tsconfig.json below the case root is a second program, +// exactly as the parser treats it, and a file is answered by the program whose directory +// is nearest above it — so a case can compile one directory strict and another loose +// (#416). A case with only the root tsconfig loads one program, as before. +const caseRoot = path.resolve(process.argv[2] ?? '.'); +const programDirs = [caseRoot]; +(function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const sub = path.join(d, e.name); + if (fs.existsSync(path.join(sub, 'tsconfig.json'))) programDirs.push(sub); + walk(sub); + } +})(caseRoot); +const programs = programDirs.map((dir) => ({ + dir, ...loadProgram(process.argv[2], process.argv[3], 'tsc_oracle_case', dir), +})); +const governing = (file) => programs + .filter((p) => file === p.dir || file.startsWith(p.dir + path.sep)) + .reduce((a, b) => (b.dir.length > a.dir.length ? b : a)); const pairs = new Set(); -for (const sf of program.getSourceFiles()) { - if (!own.has(path.resolve(sf.fileName))) continue; - const visit = (node) => { - // A DECORATOR APPLICATION IS A CALL, and this side did not think so. The project - // oracle enumerates `ts.isDecorator` and calls it DECORATOR_CALL; this list omitted - // it, so the per-case suite was blind to decorators entirely — green on them whatever - // the engine did, while a project run counted 191 absences on a decorator-driven - // codebase. No case had ever used a decorator, so nothing caught the disagreement. - // Whichever side is right, both must say it, and the compiler settles it: it resolves - // the application, because a decorator is a function invoked with (target, key, - // descriptor). #233. - if (ts.isCallExpression(node) || ts.isNewExpression(node) - || ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node) - || ts.isTaggedTemplateExpression(node) || ts.isDecorator(node)) { - let sig; - try { sig = checker.getResolvedSignature(node); } catch { sig = undefined; } - let decl = sig?.declaration; - // AN IMPLICIT CONSTRUCTOR HAS NO DECLARATION. `new Bag()` on a class that - // declares no constructor anywhere in its chain resolves to a signature whose - // `declaration` is undefined, so the site was silently unscored — and the - // engine's answer for it, whatever it was, went unchecked. The parser now - // synthesises that constructor on the ROOT class of the `extends` chain (the - // one that extends nothing; a subclass runs its base's), so the compiler side - // names the same declaration: the root class, labelled as its ``. #583. - let target; - if (decl === undefined && ts.isNewExpression(node)) { - const cls = implicitCtorOwner(node); - target = cls === undefined ? undefined : labelOfImplicitCtor(cls); - } else { - target = labelOf(decl); +for (const { dir, ts, program, checker, own, labelOf, callerOf, + implicitCtorOwner, labelOfImplicitCtor } of programs) { + for (const sf of program.getSourceFiles()) { + const file = path.resolve(sf.fileName); + if (!own.has(file) || governing(file).dir !== dir) continue; + const visit = (node) => { + // A DECORATOR APPLICATION IS A CALL, and this side did not think so. The project + // oracle enumerates `ts.isDecorator` and calls it DECORATOR_CALL; this list omitted + // it, so the per-case suite was blind to decorators entirely — green on them whatever + // the engine did, while a project run counted 191 absences on a decorator-driven + // codebase. No case had ever used a decorator, so nothing caught the disagreement. + // Whichever side is right, both must say it, and the compiler settles it: it resolves + // the application, because a decorator is a function invoked with (target, key, + // descriptor). #233. + if (ts.isCallExpression(node) || ts.isNewExpression(node) + || ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node) + || ts.isTaggedTemplateExpression(node) || ts.isDecorator(node)) { + let sig; + try { sig = checker.getResolvedSignature(node); } catch { sig = undefined; } + let decl = sig?.declaration; + // AN IMPLICIT CONSTRUCTOR HAS NO DECLARATION. `new Bag()` on a class that + // declares no constructor anywhere in its chain resolves to a signature whose + // `declaration` is undefined, so the site was silently unscored — and the + // engine's answer for it, whatever it was, went unchecked. The parser now + // synthesises that constructor on the ROOT class of the `extends` chain (the + // one that extends nothing; a subclass runs its base's), so the compiler side + // names the same declaration: the root class, labelled as its ``. #583. + let target; + if (decl === undefined && ts.isNewExpression(node)) { + const cls = implicitCtorOwner(node); + target = cls === undefined ? undefined : labelOfImplicitCtor(cls); + } else { + target = labelOf(decl); + } + if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); } - if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); - } - // AN ACCESSOR IS INVOKED BY THE ACCESS. `c.req.url` runs `get url()` and `c.res = r` - // runs `set res(v)`, and the compiler knows which declaration each is: the symbol at - // the property name carries the get and set declarations. A read names the getter; an - // assignment target names the setter; a compound assignment or an update (`x.n += 1`, - // `x.n++`) reads then writes and names both. The engine emits these as PROPERTY_READ / - // PROPERTY_WRITE edges (#703), and without this they would be unscored extras: the - // one shape whose ground truth is the compiler's and was never asked of it. - if (ts.isPropertyAccessExpression(node)) { - let sym; - try { sym = checker.getSymbolAtLocation(node.name); } catch { sym = undefined; } - if (sym && (sym.flags & ts.SymbolFlags.Alias)) sym = checker.getAliasedSymbol(sym); - if (sym && (sym.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor))) { - const parent = node.parent; - const isLeft = ts.isBinaryExpression(parent) && parent.left === node - && parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment - && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment; - const plain = isLeft && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - const update = (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) - && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken); - const reads = !plain; - const writes = isLeft || update; - for (const d of sym.declarations ?? []) { - if ((reads && ts.isGetAccessorDeclaration(d)) || (writes && ts.isSetAccessorDeclaration(d))) { - const target = labelOf(d); - if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); + // AN ACCESSOR IS INVOKED BY THE ACCESS. `c.req.url` runs `get url()` and `c.res = r` + // runs `set res(v)`, and the compiler knows which declaration each is: the symbol at + // the property name carries the get and set declarations. A read names the getter; an + // assignment target names the setter; a compound assignment or an update (`x.n += 1`, + // `x.n++`) reads then writes and names both. The engine emits these as PROPERTY_READ / + // PROPERTY_WRITE edges (#703), and without this they would be unscored extras: the + // one shape whose ground truth is the compiler's and was never asked of it. + if (ts.isPropertyAccessExpression(node)) { + let sym; + try { sym = checker.getSymbolAtLocation(node.name); } catch { sym = undefined; } + if (sym && (sym.flags & ts.SymbolFlags.Alias)) sym = checker.getAliasedSymbol(sym); + if (sym && (sym.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor))) { + const parent = node.parent; + const isLeft = ts.isBinaryExpression(parent) && parent.left === node + && parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment + && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + const plain = isLeft && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + const update = (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken); + const reads = !plain; + const writes = isLeft || update; + for (const d of sym.declarations ?? []) { + if ((reads && ts.isGetAccessorDeclaration(d)) || (writes && ts.isSetAccessorDeclaration(d))) { + const target = labelOf(d); + if (target !== undefined) pairs.add(`${callerOf(node)} -> ${target}`); + } } } } - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sf, visit); + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } } -const diags = diagnostics(); +// A diagnostic counts only in a file its own program governs: the root program also +// compiles the nested program's files under the root's options, and those are not the +// options the author wrote for them. +const diags = programs.flatMap((p) => p.diagnostics().filter((d) => !d.file + || governing(path.resolve(d.file.fileName)).dir === p.dir)); if (diags.length > 0) { // A case that does not typecheck has an unreliable oracle: the checker still // answers, but it answers about a program the author did not mean to write. @@ -102,7 +127,7 @@ if (diags.length > 0) { // case does not typecheck -- threw a ReferenceError instead of printing anything, and // the author saw a stack trace from the oracle rather than the compiler's message. // The branch only runs when a fixture fails to compile, which is why it survived. - const caseRoot = path.resolve(process.argv[2] ?? '.'); + const { ts } = programs[0]; for (const d of diags.slice(0, 8)) { // A diagnostic about the PROGRAM rather than a file -- a bad compiler option, a // missing lib -- carries no `d.file` either. diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index 948d5b68e..f5d28a76a 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -91,10 +91,33 @@ call_has_spread(ce) :- call_site("client", _, _, _, _, ce, cs), // target, so its count is unknown in the same sense a spread's is: arity stands down. call_has_spread(ce) :- bare_decorator_call(ce). +// ── sbca_site_rejects(CallExpr, Callee) — the other family of call/apply/bind ── +// In a MIXED project member-lookup.dl seeds BOTH `Function` and `CallableFunction` for +// a callable receiver, because the project has no single answer. A call site does: its +// module's governing tsconfig decides (module_sbca_governed), so here the family that +// module's flag rules out is dropped. Only where the other family's same-named member +// is ALSO a candidate at the site, so a receiver that reaches `Function.bind` alone +// (an interface extending Function with no call signature) keeps it under any flag. +sbca_site_rejects(ce, m) :- expr_call_candidate(ce, m), + call_site("client", _, _, _, _, ce, cs), + call_module(cs, mod), + module_sbca_governed(mod, "true"), + plain_function_member(n, m), + callable_function_member(n, other), + expr_call_candidate(ce, other). +sbca_site_rejects(ce, m) :- expr_call_candidate(ce, m), + call_site("client", _, _, _, _, ce, cs), + call_module(cs, mod), + module_sbca_governed(mod, "false"), + callable_function_member(n, m), + plain_function_member(n, other), + expr_call_candidate(ce, other). + // ── arity_match(CallExpr, Callee) ─────────────────────────────────────────── // Guarded by the candidate set, so the numeric comparison is never a cross product // of all N-argument calls against all N-parameter methods. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_expr_argc(ce, n), !call_has_spread(ce), method_min_arity(m, lo), @@ -102,6 +125,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), method_max_arity(m, hi), n <= hi. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_has_spread(ce). // AN IMPLEMENTATION'S OWN MAXIMUM IS NOT A BOUND ON THE CALL. // `const assert: (left, right) => R = x => x as any` is legal TypeScript: @@ -120,6 +144,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), // passes fewer arguments than a parameter the callee cannot default is still not // a call to it. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), call_expr_argc(ce, n), !call_has_spread(ce), method_min_arity(m, lo), @@ -129,6 +154,7 @@ arity_match(ce, m) :- expr_call_candidate(ce, m), // kept rather than dropped: prune-only means the absence of evidence never removes // the real callee. arity_match(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), !method_arity_raw(m, _, _, _). // ============================================================================ @@ -993,6 +1019,7 @@ expr_resolves_to_method(ce, m) :- arity_match(ce, m), // engine's error, not the program's. call_has_arity_match(ce) :- arity_match(ce, _). expr_resolves_to_method(ce, m) :- expr_call_candidate(ce, m), + !sbca_site_rejects(ce, m), !call_has_arity_match(ce). // ── arity_rejected(CallExpr, Callee, ArgCount, Min, Max) ──────────────────── diff --git a/graph/typescript/engine/projections/modules.dl b/graph/typescript/engine/projections/modules.dl index f3c1e2370..dc2a14298 100644 --- a/graph/typescript/engine/projections/modules.dl +++ b/graph/typescript/engine/projections/modules.dl @@ -165,3 +165,14 @@ sbca_value(v) :- ts_module(_, _, _, _, _, _, _, _, _, _, _, _, _, _, tsconfig, _ sbca_regime("STRICT") :- sbca_value("true"), !sbca_value("false"). sbca_regime("LOOSE") :- sbca_value("false"), !sbca_value("true"). sbca_regime("MIXED") :- sbca_value("true"), sbca_value("false"). + +// ── module_sbca_governed(ModuleHash, Flag) — the flag a CALL SITE is compiled under ── +// MIXED above is the right answer for the project and the wrong one for a call: each +// file is compiled under the tsconfig that governs it, so a call in a strict program +// answers with CallableFunction and a call in a loose one with Function, in the same +// repository. overload.dl reads this per call site to settle what MIXED hedged. +// The same vote guard as sbca_value: a module no tsconfig claims carries the resolver's +// default, not a measurement, and keeps the project's hedge. +module_sbca_governed(h, v) :- ts_module(_, _, _, _, _, _, _, _, _, _, _, _, _, _, tsconfig, _, _, _, _, _, _, _, _, _, _, _, h, v), + v != "", + tsconfig != "". diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index b046589a2..d8e05988e 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -176,6 +176,8 @@ .decl module_strict_bind_call_apply(c0:symbol,c1:symbol,c2:symbol) .decl sbca_value(c0:symbol) .decl sbca_regime(c0:symbol) +.decl module_sbca_governed(c0:symbol,c1:symbol) +.decl sbca_site_rejects(c0:symbol,c1:symbol) .decl expr_spread(c0:symbol,c1:symbol) .decl expr_static_type(c0:symbol,c1:symbol,c2:symbol) .decl expr_type(c0:symbol,c1:symbol,c2:symbol)