From 996fa55dd0ad21d49968d2c51e6e94ac6902ce84 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:15:02 -0700 Subject: [PATCH 1/7] typescript: a structural pair needs matching arity and a class that can reach the interface A class was counted as implementing an interface whenever it declared a member of each required name. Now a covered method that needs more arguments than the target passes rejects the pair, and outside one tsconfig program the class's module (or a module that uses the class as a value) must import the interface's module. impact never promotes a structural pair to 'must change', and a via-the-interface row reached only structurally is at most one of a set. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/bus.ts | 10 ++ .../src/duck-pub.ts | 12 ++ .../03-structural-satisfaction/src/pub.ts | 25 +++++ .../src/stranger.ts | 7 ++ .../expected/03-structural-satisfaction.edges | 11 ++ .../03-structural-satisfaction.envelope | 2 + .../03-structural-satisfaction.fields | 1 + .../03-structural-satisfaction.fields-oracle | 10 +- .../03-structural-satisfaction.lib.edges | 11 ++ .../03-structural-satisfaction.lib.envelope | 2 + .../03-structural-satisfaction.lib.oracle | 4 +- .../03-structural-satisfaction.oracle | 4 +- .../03-structural-satisfaction.type-use | 10 ++ .../03-structural-satisfaction.types-oracle | 10 +- .../resolution/structural-satisfaction.dl | 105 ++++++++++++++++-- graph/typescript/souffle/decls_all.dl | 12 ++ .../skills/axiomcode/scripts/axiomcode-impact | 5 +- .../skills/axiomcode/scripts/graph_sql.py | 15 ++- .../dispatch-base-is-a-contract/case.json | 6 +- .../dispatch-base-is-a-contract/src/router.ts | 13 +++ 20 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts new file mode 100644 index 000000000..8392d9614 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/bus.ts @@ -0,0 +1,10 @@ +import type { Pub } from "./pub"; + +// Sees Pub, shares the name `publish`, and still does not satisfy it: its publish needs +// two arguments where Pub's callers pass one. +export class Bus { + publish(name: string, payload: unknown): void {} +} + +export const bus = new Bus(); +export type Seen = Pub; diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts new file mode 100644 index 000000000..a87ee5a9f --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -0,0 +1,12 @@ +import { Relay, type Pub } from "./pub"; + +// CONTROL: no `implements`, an extra optional parameter, and passed as a Pub — it stays +// a structural candidate of Pub.publish. +export class Duck { + publish(e: { id: string }, trace?: string): void {} +} + +export function wire(): void { + const p: Pub = new Duck(); + new Relay(p).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts new file mode 100644 index 000000000..8dbcd586b --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -0,0 +1,25 @@ +// A shared method NAME is not conformance. Pub has a declared implementor (Real) and a +// conformer the program builds and passes as a Pub (Duck, in duck-pub.ts). Two more +// classes share the name `publish` and are NOT Pubs: +// * Bus (bus.ts) sees Pub but its publish needs two arguments — not assignable; +// * Stranger (stranger.ts) has the right arity but no module that holds it ever +// imports this one, so no instance of it can reach a Pub-typed slot. + +export interface Pub { + publish(e: { id: string }): void; +} + +export class Relay { + constructor(private readonly p: Pub) {} + run(): void { + this.p.publish({ id: "1" }); + } +} + +export class Real implements Pub { + publish(e: { id: string }): void {} +} + +export function start(): void { + new Relay(new Real()).run(); +} diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts new file mode 100644 index 000000000..39267f860 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/stranger.ts @@ -0,0 +1,7 @@ +// Same shape as Pub, but nothing that holds a Stranger ever imports pub.ts: no Stranger +// can be handed to code typed by Pub. +export class Stranger { + publish(e: { id: string }): void {} +} + +export const stranger = new Stranger(); diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index f5857b6e4..6547b759a 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,9 +1,20 @@ ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> - +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() +known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL pub#start() @L24 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope index 224a7991e..437f76888 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,2 +1,4 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields b/graph/test/typescript/expected/03-structural-satisfaction.fields index e69de29bb..7cdc0dfc1 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields @@ -0,0 +1 @@ +known_edge read Relay#run() -> Relay#p diff --git a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle index 05a32ce61..54cda29c4 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.fields-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [fields] - precision 0.0000 (0 correct, 0 wrong) - recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index 258f2417b..5d6dfab6a 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,9 +1,20 @@ boundary_lib FUNCTION_CALL duck#useLibrary() @L51 -> io#drain({ read(): string }) boundary_lib METHOD_CALL duck#useLibrary() @L50 -> Closeable#close() +known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) +known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL pub#start() @L24 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope index 595d139aa..5581aa106 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -1,3 +1,5 @@ +nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read structural lib:io#Closeable.close -> duck#FileReader.close +structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle index 3716c5f08..26a4cffa8 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle @@ -1,4 +1,6 @@ -oracle=7 engine=10 agree=7 missing=0 (known 0, NEW 0) extra=3 +oracle=16 engine=21 agree=16 missing=0 (known 0, NEW 0) extra=5 + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle index 110a730c3..126927cbb 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,6 @@ -oracle=5 engine=8 agree=5 missing=0 (known 0, NEW 0) extra=3 +oracle=14 engine=19 agree=14 missing=0 (known 0, NEW 0) extra=5 + extra Relay#run() -> Duck#publish({ id: string },string) + extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() extra duck#consume(Reader) -> NetReader#read() extra duck#useLibrary() -> FileReader#close() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index 83c409c6a..6b61968a3 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,4 +1,14 @@ ambiguous_unknown VARIABLE_TYPE 0 duck [VARIABLE] -> - +known_edge IMPLEMENTS_INTERFACE 0 Real [HERITAGE] -> Pub +known_edge METHOD_PARAM 0 Relay [METHOD_PARAM] -> Pub known_edge METHOD_PARAM 0 duck [METHOD_PARAM] -> Reader +known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Real +known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Relay +known_edge OBJECT_CREATION_TYPE 0 stranger [EXPRESSION] -> Stranger +known_edge TYPE_ALIAS_RHS 0 Seen [TYPE] -> Pub +known_edge VARIABLE_TYPE 0 duck-pub [VARIABLE] -> Pub diff --git a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle index de06659f1..d40ea15ff 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [types] - precision 1.0000 (3 correct, 0 wrong) - recall 1.0000 (3 of 3 the compiler resolved) - sites 5 resolved 4 (80.0%) - tiers ambiguous_unknown=1 known_edge=4 - contexts METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 VARIABLE_TYPE=1 + precision 1.0000 (13 correct, 0 wrong) + recall 1.0000 (13 of 13 the compiler resolved) + sites 15 resolved 14 (93.3%) + tiers ambiguous_unknown=1 known_edge=14 + contexts IMPLEMENTS_INTERFACE=1 METHOD_PARAM=2 OBJECT_CREATION_TYPE=9 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 not scored: 1 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index 2753406b4..fbbeedc03 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -17,10 +17,14 @@ // // Keeping it off the primary path is deliberate. It is the one relation in this // engine that could FABRICATE rather than over-approximate, because it is name-based -// and does not compare member TYPES; a class with a `read()` that takes different -// arguments would still be counted as satisfying `Readable`. That is acceptable for -// widening a prune-only test and for a labelled reachability fan; it would not be -// acceptable as the answer to "what does this call resolve to". +// and does not compare member TYPES; a class with a `read(s: string)` would still be +// counted as satisfying `Readable { read(n: number) }`. Two cheap tests keep the worst +// of it out: a method that needs MORE arguments than the target passes is rejected +// (sat_arity_conflict), and a class no code can ever hand to the interface's slot is +// not an implementor (sat_can_meet). That is acceptable for widening a prune-only test +// and for a labelled reachability fan; it would not be acceptable as the answer to +// "what does this call resolve to", and a consumer never treats the `structural` basis +// as a declared contract. // // ── THE SOUNDNESS ARGUMENT, STATED ────────────────────────────────────────── // Direction: SOURCE is assignable to TARGET when the source has a member for every @@ -98,13 +102,53 @@ sat_covered(s, t, name) :- sat_pair_seed(s, t), sat_cover_count(s, t, n) :- sat_pair_seed(s, t), n = count : { sat_covered(s, t, _) }. +// ── sat_arity_conflict(Source, Target) — a shared name that cannot be called the same way +// A NAME MATCH IS NOT A MEMBER MATCH. `publish(name, payload)` shares a name with the +// interface's `publish(envelope)` and is not assignable to it: a method that needs more +// arguments than the target's signature passes is rejected by the compiler, whatever +// the types. So a covered METHOD name conflicts when no source method of that name +// needs at most as many arguments as some target method of that name takes. Fewer +// required parameters is fine (`read()` satisfies `read(n: number)`), and so is an +// optional or rest parameter. Only a pair where BOTH sides have arity facts can conflict: +// a member without them (a field holding a function) is left to the name test. +sat_target_method(t, name, m) :- target_required_member(t, name), + scope_sibling(t, sib), + declared_method(sib, name, "false", m). +sat_target_method(t, name, m) :- target_required_member(t, name), + group_of(t, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + scope_sibling(s, sib), + declared_method(sib, name, "false", m). +sat_source_method(s, name, m) :- sat_pair_seed(s, _), + group_of(s, g), + ancestor_of_merged_group(_, g, anc), + scope_sibling(anc, asib), + declared_method(asib, name, "false", m). +sat_arity_ok(s, t, name) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + sat_target_method(t, name, tm), + method_min_arity(sm, lo), + method_max_arity(tm, hi), + lo <= hi. +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, sm), + method_min_arity(sm, _), + sat_target_method(t, name, tm), + method_max_arity(tm, _), + !sat_arity_ok(s, t, name). + // ── type_satisfies(SourceTypeHash, TargetTypeHash) ────────────────────────── -// Every required member covered. `k > 0` excludes the empty interface, which +// Every required member covered, and no covered method that could not be called with +// the target's arguments. `k > 0` excludes the empty interface, which // everything satisfies and which therefore carries no information — admitting it // would make every class an implementor of every marker interface in the tree. type_satisfies(s, t) :- sat_cover_count(s, t, k), target_required_count(t, k), - k > 0. + k > 0, + !sat_arity_conflict(s, t). // A nominal implements clause is satisfaction too, and it is the authoritative kind: // the programmer asserted it and the compiler checked it. Included here so consumers @@ -121,7 +165,8 @@ structural_implementor(t, s) :- type_satisfies(s, t), satisfaction_target(t), s != t, !target_has_nominal_implementor(t), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). // ── sat_cross_program(Source, Target) — a shape match no value can cross (#1574) ── // A CLIENT GLOBAL belongs to one program (module-graph.dl). A class in ANOTHER program @@ -204,7 +249,51 @@ structural_implementor(t, s) :- type_satisfies(s, t), target_has_nominal_implementor(t), !implementors(t, s), type_instantiated(s, _), - !sat_cross_program(s, t). + !sat_cross_program(s, t), + sat_can_meet(s, t). + +// ── sat_can_meet(Source, Target) — can a value of the class ever reach the interface's slot +// A SHAPE MATCH BETWEEN TWO CODEBASES THAT NEVER MEET IS NOT A CONFORMANCE. In a monorepo +// one service's event bus and another service's publisher interface share a method name, +// and nothing in either program can hand the one to the other. For an instance of S to +// flow into a slot typed T, some code has to see both: S's own module, or a module that +// uses S as a value (`new S()`, `useClass: S`), must import T's module, directly or +// through other modules and re-exports. Admitted without the walk: +// * S and T in one tsconfig program, or in one module — the program sees both; +// * T a library interface, or either side in a global script — no import is needed +// to see it, so the import graph proves nothing. +// A program-level dependency is deliberately NOT enough: a test that boots two services +// together makes each app depend on the other, and every name the two share would come +// back. The import walk runs only for the pairs left, from the modules that hold S. +sat_meet_pair(s, t) :- type_satisfies(s, t), + satisfaction_target(t), + s != t, + !implementors(t, s). +sat_client_type(t) :- type_module("client", _, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_program(s, p), type_program(t, p). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), type_module("client", m, t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), !sat_client_type(t). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, s), module_is_global("client", m). +sat_meets_trivially(s, t) :- sat_meet_pair(s, t), type_module("client", m, t), module_is_global("client", m). +sat_meet_open(s, t) :- sat_meet_pair(s, t), !sat_meets_trivially(s, t). + +sat_holder_module(s, m) :- sat_meet_open(s, _), type_module("client", m, s). +sat_holder_module(s, m) :- sat_meet_open(s, _), + expr_referenced("client", "TYPE", s, e), + expr_module("client", m, e). +sat_module_dep(a, b) :- import_binding("client", _, _, _, a, h), + import_target_module(h, "client", b). +sat_module_dep(a, b) :- export_decl("client", _, _, _, a, h), + export_target_module(h, "client", b). +sat_module_reach(m, m) :- sat_holder_module(_, m). +sat_module_reach(o, b) :- sat_module_reach(o, a), + sat_module_dep(a, b). + +sat_can_meet(s, t) :- sat_meets_trivially(s, t). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + type_module("client", mt, t), + sat_module_reach(o, mt). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb37..b934c4655 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -709,3 +709,15 @@ .decl dispatch_assumes_closed_world(c0:symbol,c1:symbol) .decl type_constructible(c0:symbol) .decl type_live(c0:symbol) +.decl sat_arity_conflict(c0:symbol,c1:symbol) +.decl sat_arity_ok(c0:symbol,c1:symbol,c2:symbol) +.decl sat_can_meet(c0:symbol,c1:symbol) +.decl sat_client_type(c0:symbol) +.decl sat_holder_module(c0:symbol,c1:symbol) +.decl sat_meet_open(c0:symbol,c1:symbol) +.decl sat_meet_pair(c0:symbol,c1:symbol) +.decl sat_meets_trivially(c0:symbol,c1:symbol) +.decl sat_module_dep(c0:symbol,c1:symbol) +.decl sat_module_reach(c0:symbol,c1:symbol) +.decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4343911ff..bf74f2d8e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1156,8 +1156,11 @@ class Impact: # implements `Router.add` whether or not anything constructs a TrieRouter, and a signature change breaks it # either way. Read straight from dispatch_candidates so the contract rule does not inherit the closure's # filter — which is what made the rules and the hook's fast path disagree on exactly those candidates. + # A `structural` pair is a shape match nobody declared: evidence that the class MAY be passed as the + # interface, not that it implements it, so it never makes the declaration a must-change contract. W('implements_pair', sorted({(r[0], r[1]) for r in g.q( - "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id")}) + "SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE base_method_id <> candidate_method_id" + " AND basis <> 'structural'")}) if g.has('dispatch_candidates') else []) # the pairs whose base is a FUNCTION TYPE and whose candidate is a function stored in a field of it (#1206): # the callers of the base call the candidate through that field (impact.dl, `value_pair`) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index b781a96c9..ce5ae73f3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -950,11 +950,15 @@ def via_base_rows(q, lines=None, stubs=frozenset(), only=None): carries no edge to b: that caller is a caller of b when the receiver it reads there is a field declared with b's type. Without it, `impact` on the interface method said nothing depended on it (#1542).""" if not (_has(q, 'call_edges') and _has(q, 'call_sites')): return [], set() - pairs = set() + pairs, shape_only = set(), set() if _has(q, 'overrides'): pairs |= {(b, o) for b, o in q("SELECT method_id, overriding_method_id FROM overrides")} if _has(q, 'dispatch_candidates'): - pairs |= {(b, o) for b, o in q("SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE basis <> 'value'")} + declared = set(pairs) + for b, o, basis in q("SELECT base_method_id, candidate_method_id, basis FROM dispatch_candidates WHERE basis <> 'value'"): + pairs.add((b, o)) + (shape_only if basis == 'structural' else declared).add((b, o)) + shape_only -= declared # a shape match nobody declared is never the one thing that runs down = collections.defaultdict(set) for b, o in pairs: if b and o and b != o: down[b].add(o) @@ -1020,7 +1024,8 @@ def recv_types(c, sp): others = set(T) - {b} for m in subs[b]: if others and m not in others: continue - rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if n == 1 else 'one of a set', f, l, m if m in T else b)) + sole = n == 1 and (b, m) not in shape_only + rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if sole else 'one of a set', f, l, m if m in T else b)) sites.add((c, m, f, l)) if typed_on or len(T) != 1: continue (o, t), = T.items() @@ -1715,10 +1720,12 @@ def contract_for_method(q, ids): if not q("SELECT 1 FROM overrides LIMIT 1"): out += _name_match_contract(q, ids) # the dispatch base the engine records no override row for (#1011): read from dispatch_candidates UNFILTERED, # because whether a declaration implements an interface method is not a question about reachability — the rules - # read `implements_pair`, which is the same table without the closure's RTA filter. + # read `implements_pair`, which is the same table without the closure's RTA filter. A `structural` pair is a + # shape match nobody declared, so it is not a contract (axiomcode-impact excludes it from implements_pair too). if q("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'"): for (b,) in q(f"""SELECT DISTINCT dc.base_method_id FROM dispatch_candidates dc WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis <> 'structural' AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): if b not in ids: out.append((b, 'it implements this — the engine records a dispatch candidate here and no override row')) diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/case.json b/tests/cases/typescript/dispatch-base-is-a-contract/case.json index 6467788c5..0afd462a6 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/case.json +++ b/tests/cases/typescript/dispatch-base-is-a-contract/case.json @@ -11,5 +11,9 @@ {"why": "the contract holds whether or not anything constructs the implementation: TrieRouter is never instantiated, so the closure's RTA filter drops its dispatch edge, and reading the implements relation through that filter made the rules and the fast path disagree", "run": ["impact", "TrieRouter.add"], "want": ["Router.add", "it implements this"], - "avoid": []} + "avoid": []}, + {"why": "a class that only matches the interface's SHAPE is a dispatch candidate, not a declared contract: its callers are still reached through the base, but the base is never 'must change - it implements this'", + "run": ["impact", "DuckRouter.add"], + "want": ["App.mount"], + "avoid": ["it implements this", "must change with it"]} ]} diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts index f7a0bf473..6a82bf5cb 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts +++ b/tests/cases/typescript/dispatch-base-is-a-contract/src/router.ts @@ -25,3 +25,16 @@ export class App { this.router.add(path); // typed to the interface: the dispatch base } } + +// No `implements`: it fits Router's shape and is passed as one, so it may run at `mount` — but nothing +// declared the contract, so a change to it does not have to change Router.add. +export class DuckRouter { + add(path: string): void { + this.last = path; + } + last = ''; +} + +export function duckApp(): App { + return new App(new DuckRouter()); +} From 167adac6009da1d438352e701324d60100210196 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:09:04 -0700 Subject: [PATCH 2/7] typescript: a private or protected method never satisfies an interface member The compiler rejects assigning a class whose only member of a required name is private, protected or #private, so the structural rule no longer counts such a class as a conformer. The structural-satisfaction case gains a same-arity class with a private method, built in the interface's module, that must not become a dispatch candidate. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/duck-pub.ts | 6 ++++++ .../expected/03-structural-satisfaction.edges | 7 ++++--- .../expected/03-structural-satisfaction.lib.edges | 7 ++++--- .../expected/03-structural-satisfaction.type-use | 1 + .../engine/resolution/structural-satisfaction.dl | 15 +++++++++++++++ graph/typescript/souffle/decls_all.dl | 2 ++ 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts index a87ee5a9f..6af8cd5e5 100644 --- a/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -6,6 +6,12 @@ export class Duck { publish(e: { id: string }, trace?: string): void {} } +// Built beside a Pub and the right arity, but its publish is private: not assignable to Pub. +export class Hidden { + private publish(e: { id: string }): void {} +} +export const hidden = new Hidden(); + export function wire(): void { const p: Pub = new Duck(); new Relay(p).run(); diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index 6547b759a..b49461809 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -3,14 +3,15 @@ known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() -known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() known_edge METHOD_CALL pub#start() @L24 -> Relay#run() multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index 5d6dfab6a..fac2a511d 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -4,13 +4,14 @@ known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L10 -> Duck#() -known_edge CONSTRUCTOR_CALL duck-pub#wire() @L11 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() +known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) -known_edge METHOD_CALL duck-pub#wire() @L11 -> Relay#run() +known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() known_edge METHOD_CALL pub#start() @L24 -> Relay#run() multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index 6b61968a3..c27013e8b 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -6,6 +6,7 @@ known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck +known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Hidden known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Relay known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Real known_edge OBJECT_CREATION_TYPE 0 pub [EXPRESSION] -> Relay diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index fbbeedc03..e28a0ed35 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -139,6 +139,21 @@ sat_arity_conflict(s, t) :- sat_covered(s, t, name), sat_target_method(t, name, tm), method_max_arity(tm, _), !sat_arity_ok(s, t, name). +// A `private` or `protected` method never satisfies an interface member of its name: the +// compiler rejects the assignment ("property is private in type S but not in type T"), +// so a class whose only method of that name is hidden is not a conformer. A covered name +// the class also declares visibly (or as a field) is left alone. +sat_member_hidden("PRIVATE_ACCESS"). +sat_member_hidden("PROTECTED_ACCESS"). +sat_member_hidden("PRIVATE_NAME_ACCESS"). +sat_visible_source_method(s, name) :- sat_source_method(s, name, m), + method_access(_, acc, m), + !sat_member_hidden(acc). +sat_arity_conflict(s, t) :- sat_covered(s, t, name), + sat_source_method(s, name, m), + method_access(_, acc, m), + sat_member_hidden(acc), + !sat_visible_source_method(s, name). // ── type_satisfies(SourceTypeHash, TargetTypeHash) ────────────────────────── // Every required member covered, and no covered method that could not be called with diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index b934c4655..d42b7b481 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -714,6 +714,7 @@ .decl sat_can_meet(c0:symbol,c1:symbol) .decl sat_client_type(c0:symbol) .decl sat_holder_module(c0:symbol,c1:symbol) +.decl sat_member_hidden(c0:symbol) .decl sat_meet_open(c0:symbol,c1:symbol) .decl sat_meet_pair(c0:symbol,c1:symbol) .decl sat_meets_trivially(c0:symbol,c1:symbol) @@ -721,3 +722,4 @@ .decl sat_module_reach(c0:symbol,c1:symbol) .decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) +.decl sat_visible_source_method(c0:symbol,c1:symbol) From 6de4d920ad54e4e680732582725719ffe550b30e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:22:03 -0700 Subject: [PATCH 3/7] typescript: an import the walk cannot follow that names the interface lets its class meet it The reachability gate walks imports from the class's modules to the interface's module. A workspace package whose manifest points at absent build output, or a compiled .d.ts, binds no client module, so the walk stopped there and dropped true conformers (a class declaring implements through such a package, or one built by a factory module that imports the interface that way). An import in a reached module that names the interface now counts as the meeting. Control Declared in 03-structural-satisfaction: without the rule it loses Relay.run -> Declared.publish; Stranger and Bus stay absent. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../03-structural-satisfaction/src/declared.ts | 12 ++++++++++++ .../cases/03-structural-satisfaction/src/pub.ts | 2 ++ .../expected/03-structural-satisfaction.edges | 14 ++++++++------ .../expected/03-structural-satisfaction.entries | 10 ++++++++++ .../expected/03-structural-satisfaction.envelope | 1 + .../expected/03-structural-satisfaction.lib.edges | 14 ++++++++------ .../03-structural-satisfaction.lib.envelope | 1 + .../03-structural-satisfaction.lib.oracle | 3 ++- .../expected/03-structural-satisfaction.oracle | 3 ++- .../expected/03-structural-satisfaction.type-use | 2 ++ .../03-structural-satisfaction.types-oracle | 12 ++++++------ .../engine/resolution/structural-satisfaction.dl | 15 +++++++++++++++ graph/typescript/souffle/decls_all.dl | 1 + 13 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts create mode 100644 graph/test/typescript/expected/03-structural-satisfaction.entries diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts new file mode 100644 index 000000000..e8b111a51 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/declared.ts @@ -0,0 +1,12 @@ +// Brings Pub in through a package specifier the engine cannot follow to pub.ts (a +// workspace package whose build output is absent), so the import walk never reaches Pub's +// module. The import still names Pub, and that is the evidence: Declared stays a Pub +// candidate. +// @ts-expect-error the package is not installed in this case +import type { Pub } from "@workspace/pub"; + +export class Declared implements Pub { + publish(e: { id: string }): void {} +} + +export const declared = new Declared(); diff --git a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts index 8dbcd586b..cc19ffdeb 100644 --- a/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -4,6 +4,8 @@ // * Bus (bus.ts) sees Pub but its publish needs two arguments — not assignable; // * Stranger (stranger.ts) has the right arity but no module that holds it ever // imports this one, so no instance of it can reach a Pub-typed slot. +// The control for Stranger is Declared (declared.ts): it never reaches this module either, +// but it imports a Pub from a package the walk cannot follow, so it stays a candidate. export interface Pub { publish(e: { id: string }): void; diff --git a/graph/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges index b49461809..46193873d 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,21 +1,23 @@ ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> - known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck#useLibrary() @L50 -> FileReader#close() known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() -known_edge METHOD_CALL pub#start() @L24 -> Relay#run() -multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) -multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) -multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.entries b/graph/test/typescript/expected/03-structural-satisfaction.entries new file mode 100644 index 000000000..1dc8130f9 --- /dev/null +++ b/graph/test/typescript/expected/03-structural-satisfaction.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module duck#consume duck.ts:31 + exported_from_entry_module duck#drive duck.ts:35 + exported_from_entry_module duck#useLibrary duck.ts:45 + exported_from_entry_module duck-pub#wire duck-pub.ts:15 + unimported_module bus# bus.ts:1 + unimported_module declared# declared.ts:1 + unimported_module duck# duck.ts:1 + unimported_module duck-pub# duck-pub.ts:1 + unimported_module stranger# stranger.ts:1 diff --git a/graph/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope index 437f76888..e5fbd8c49 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,4 +1,5 @@ nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read +structural pub#Pub.publish -> declared#Declared.publish structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges index fac2a511d..ac4f8304f 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,21 +1,23 @@ boundary_lib FUNCTION_CALL duck#useLibrary() @L51 -> io#drain({ read(): string }) boundary_lib METHOD_CALL duck#useLibrary() @L50 -> Closeable#close() known_edge CONSTRUCTOR_CALL bus#() @L9 -> Bus#() +known_edge CONSTRUCTOR_CALL declared#() @L12 -> Declared#() known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#() known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#() known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#() known_edge CONSTRUCTOR_CALL duck-pub#() @L13 -> Hidden#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#() known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#(Pub) -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Real#() -known_edge CONSTRUCTOR_CALL pub#start() @L24 -> Relay#(Pub) +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#() +known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#(Pub) known_edge CONSTRUCTOR_CALL stranger#() @L7 -> Stranger#() known_edge FUNCTION_CALL duck#drive() @L39 -> duck#consume(Reader) known_edge METHOD_CALL duck-pub#wire() @L17 -> Relay#run() -known_edge METHOD_CALL pub#start() @L24 -> Relay#run() -multi_inferred METHOD_CALL Relay#run() @L15 -> Duck#publish({ id: string },string) -multi_inferred METHOD_CALL Relay#run() @L15 -> Pub#publish({ id: string }) -multi_inferred METHOD_CALL Relay#run() @L15 -> Real#publish({ id: string }) +known_edge METHOD_CALL pub#start() @L26 -> Relay#run() +multi_inferred METHOD_CALL Relay#run() @L17 -> Declared#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Duck#publish({ id: string },string) +multi_inferred METHOD_CALL Relay#run() @L17 -> Pub#publish({ id: string }) +multi_inferred METHOD_CALL Relay#run() @L17 -> Real#publish({ id: string }) multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> FileReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> NetReader#read() multi_inferred METHOD_CALL duck#consume(Reader) @L32 -> Reader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope index 5581aa106..620432e53 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -2,4 +2,5 @@ nominal pub#Pub.publish -> pub#Real.publish structural duck#Reader.read -> duck#FileReader.read structural duck#Reader.read -> duck#NetReader.read structural lib:io#Closeable.close -> duck#FileReader.close +structural pub#Pub.publish -> declared#Declared.publish structural pub#Pub.publish -> duck-pub#Duck.publish diff --git a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle index 26a4cffa8..e196b8052 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle @@ -1,4 +1,5 @@ -oracle=16 engine=21 agree=16 missing=0 (known 0, NEW 0) extra=5 +oracle=18 engine=24 agree=18 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) extra Relay#run() -> Duck#publish({ id: string },string) extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle index 126927cbb..7546b16d7 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,5 @@ -oracle=14 engine=19 agree=14 missing=0 (known 0, NEW 0) extra=5 +oracle=16 engine=22 agree=16 missing=0 (known 0, NEW 0) extra=6 + extra Relay#run() -> Declared#publish({ id: string }) extra Relay#run() -> Duck#publish({ id: string },string) extra Relay#run() -> Real#publish({ id: string }) extra duck#consume(Reader) -> FileReader#read() diff --git a/graph/test/typescript/expected/03-structural-satisfaction.type-use b/graph/test/typescript/expected/03-structural-satisfaction.type-use index c27013e8b..04be63a89 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,8 +1,10 @@ +ambiguous_unknown IMPLEMENTS_INTERFACE 0 Declared [HERITAGE] -> - ambiguous_unknown VARIABLE_TYPE 0 duck [VARIABLE] -> - known_edge IMPLEMENTS_INTERFACE 0 Real [HERITAGE] -> Pub known_edge METHOD_PARAM 0 Relay [METHOD_PARAM] -> Pub known_edge METHOD_PARAM 0 duck [METHOD_PARAM] -> Reader known_edge OBJECT_CREATION_TYPE 0 bus [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 declared [EXPRESSION] -> Declared known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> FileReader known_edge OBJECT_CREATION_TYPE 0 duck [EXPRESSION] -> NetReader known_edge OBJECT_CREATION_TYPE 0 duck-pub [EXPRESSION] -> Duck diff --git a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle index d40ea15ff..d2852d663 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.types-oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.types-oracle @@ -1,7 +1,7 @@ 03-structural-satisfaction [types] - precision 1.0000 (13 correct, 0 wrong) - recall 1.0000 (13 of 13 the compiler resolved) - sites 15 resolved 14 (93.3%) - tiers ambiguous_unknown=1 known_edge=14 - contexts IMPLEMENTS_INTERFACE=1 METHOD_PARAM=2 OBJECT_CREATION_TYPE=9 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 - not scored: 1 rows whose target is not a client declaration + precision 1.0000 (15 correct, 0 wrong) + recall 1.0000 (15 of 15 the compiler resolved) + sites 18 resolved 16 (88.9%) + tiers ambiguous_unknown=2 known_edge=16 + contexts IMPLEMENTS_INTERFACE=2 METHOD_PARAM=2 OBJECT_CREATION_TYPE=11 TYPE_ALIAS_RHS=1 VARIABLE_TYPE=2 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl index e28a0ed35..c2ab16dfe 100644 --- a/graph/typescript/engine/resolution/structural-satisfaction.dl +++ b/graph/typescript/engine/resolution/structural-satisfaction.dl @@ -309,6 +309,21 @@ sat_can_meet(s, t) :- sat_meet_open(s, t), sat_holder_module(s, o), type_module("client", mt, t), sat_module_reach(o, mt). +// THE WALK STOPS WHERE AN IMPORT LEADS OUT OF THE CLIENT: a workspace package whose +// manifest points at build output that is not there, or at a compiled .d.ts, binds no +// client module, so the walk cannot tell whether T's module is behind it. An import +// there that NAMES the interface is the evidence instead: the class's module, or one it +// reaches, brings in a type called T from a place the walk cannot follow. +sat_opaque_import_name(m, name) :- import_binding("client", _, name, _, m, h), + !import_target_module(h, "client", _). +sat_opaque_import_name(m, name) :- import_binding("client", _, _, name, m, h), + name != "", + !import_target_module(h, "client", _). +sat_can_meet(s, t) :- sat_meet_open(s, t), + sat_holder_module(s, o), + sat_module_reach(o, m), + sat_opaque_import_name(m, name), + type_decl("client", name, _, _, _, _, t). // ── satisfaction_unmeasured(TargetTypeHash, Name) ─────────────────────────── // EVERY SUPPRESSION COUNTABLE. A required member of a tested interface that NO diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index d42b7b481..050acd2a8 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -720,6 +720,7 @@ .decl sat_meets_trivially(c0:symbol,c1:symbol) .decl sat_module_dep(c0:symbol,c1:symbol) .decl sat_module_reach(c0:symbol,c1:symbol) +.decl sat_opaque_import_name(c0:symbol,c1:symbol) .decl sat_source_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_target_method(c0:symbol,c1:symbol,c2:symbol) .decl sat_visible_source_method(c0:symbol,c1:symbol) From 4e9d3071327dd61ab816fc66117e1e7e4cf71062 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:56:39 -0700 Subject: [PATCH 4/7] javascript: a call through a field holding a platform-made wrapper of a project function reaches that function A field or module variable set to what a platform call returned when handed a project function (promisify(store.find.bind(store))) was an ambient terminal, and path called the caller independent of the very method the wrapper runs. The wrapper call now reaches the bound method when the graph knows it (one of a set), and is an open value callee when it does not. path also counts a call that ends on a function type's signature (TypeScript) as a value callee, and lists a value callee named like the other endpoint first. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 31 ++++++++ graph/javascript/souffle/decls_all.dl | 5 ++ .../skills/axiomcode/scripts/axiomcode-path | 30 ++++++-- .../stored-field-callee-shapes/case.json | 75 +++++++++++++++++++ .../stored-field-callee-shapes/src/shapes.js | 11 ++- .../function-stored-in-a-holder/case.json | 17 +++++ .../function-stored-in-a-holder/src/app.ts | 14 ++++ 7 files changed, 176 insertions(+), 7 deletions(-) diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index 34fbd6b81..a257ba732 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -107,6 +107,37 @@ module_variable_from_call(v) :- var_init("client", _, e, v), expr_kind(_, k, _, !variable_reassigned(v), !call_passes_function(e). call_passes_function(e) :- call_arg(e, _, a), expr_value(a, "func", _). call_passes_function(e) :- call_arg(e, _, a), expr_introduces(_, _, a). +// `promisify(store.find.bind(store))`: a `.bind` always evaluates to a function, typed +// receiver or not. A bound platform function (`Math.max.bind(Math)`) is the platform's. +call_passes_function(e) :- call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_child(_, a, "CALLEE", _, f), !bound_platform_function(f). +bound_platform_function(f) :- expr_kind(_, "PROPERTY_ACCESS", _, f), expr_child(_, f, "ACCESS_TARGET", _, r), + expr_value(r, "ambient", _). +// A holder whose value is what a PLATFORM call returned when handed a project function +// (`this.find = promisify(s.find.bind(s))`, `const f = util.callbackify(g)`): the value +// is a wrapper the platform made around that function, and calling it runs the +// function. The platform value on the holder made the call an ambient terminal, a +// claimed correct end, and path said "independent" of the very method the wrapper +// runs. A platform value made from no project function (`promisify(setTimeout)`) +// keeps its platform reading. +// When the graph knows the function the wrapper was made from (a function value, or +// the method a `.bind` site resolves to), the call runs it: one of a set, beside the +// platform row the call keeps. Only when it knows none is the callee an open value. +unresolved_value_callee(ce, "field") :- wrapper_holder_call(ce, val), !wrapper_call_target(ce, _), made_from_function(val), + field_call(ce, _, _, _). +unresolved_value_callee(ce, "module_variable") :- wrapper_holder_call(ce, e), !wrapper_call_target(ce, _), made_from_function(e), + !field_call(ce, _, _, _). +wrapper_holder_call(ce, val) :- field_call(ce, k, t, n), !call_resolved(ce), field_assignment(k, t, n, val). +wrapper_holder_call(ce, e) :- value_callee_unresolved(ce, c), expr_binding(_, v, c), + var_decl("client", _, _, _, _, _, v), !var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c), + var_init("client", _, e, v), !variable_reassigned(v). +made_from_function(e) :- expr_kind(_, "CALL", _, e), expr_value(e, "ambient", _), call_passes_function(e). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), expr_value(a, "func", m). +wrapper_runs(e, m) :- made_from_function(e), call_arg(e, _, a), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, a, _, _), + expr_resolves_to_method(a, m). +wrapper_call_target(ce, m) :- wrapper_holder_call(ce, e), wrapper_runs(e, m), call_target_count(ce, 0). +call_chain_edge(ce, caller, "-", m, "client", "multi_inferred", kind) :- + wrapper_call_target(ce, m), !call_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). variable_reassigned(v) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt). // `(c ? a : b)()`, `(0, cb)()`, `make()()`: the callee is computed by an expression. diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17b..e0f899c5c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -103,6 +103,11 @@ .decl module_variable_from_call(c0:symbol) .decl variable_reassigned(c0:symbol) .decl call_passes_function(c0:symbol) +.decl bound_platform_function(c0:symbol) +.decl made_from_function(c0:symbol) +.decl wrapper_holder_call(c0:symbol, c1:symbol) +.decl wrapper_runs(c0:symbol, c1:symbol) +.decl wrapper_call_target(c0:symbol, c1:symbol) .decl live_export_variable(c0:symbol, c1:symbol) .decl this_type_open(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f1..84efa1c94 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -71,6 +71,8 @@ def chain_json(g, chain): # declarations that describe a callable and have no body (score.py's bodiless_kinds) BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE', 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} +# of those, the ones a call through a VALUE lands on: a function type or a bare call signature, not a member of an interface +FUNCTION_TYPE_KINDS = {'FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE'} # the one name a front end gives every lambda it declares (Python and C#; Java declares none): a name that says nothing # about WHICH lambda, so it is never a target on its own (G.lambda_label, G.lambda_target) LAMBDA_NAMES = {''} @@ -1767,8 +1769,18 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # `table[k]()`: nothing about the name narrows the target, so the by-name search above finds no lead and # "independent" was printed for a start that hands control to whatever it was given. The engine marks such a # site (`unresolved_value_callee`); one in either side's closure makes the connection unknown, not absent. + # A typed front end RESOLVES the same call, to the holder's function type (`find: (e: string) => …`), a signature + # with no body: the chain ends there, and the function the holder is given is what runs. Those count too. opaque = [] - if not (remote_found or lib_side or sends) and g.has('ext_unresolved_value_callee'): + value_sql = [] + if g.has('ext_unresolved_value_callee'): + value_sql.append("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" + " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)") + if g.has('methods') and g.has('call_edges'): + value_sql.append("SELECT DISTINCT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, 'function_type' why FROM call_edges e" + " JOIN call_sites s ON s.id = e.call_site_id JOIN methods m ON m.id = e.callee_method_id" + f" WHERE m.kind IN ({','.join(repr(k) for k in sorted(FUNCTION_TYPE_KINDS))}) AND s.caller_id IN (%s)") + if not (remote_found or lib_side or sends) and value_sql: for xs, lx in ((A_, la), (B_, lb)): seen = {i for i in xs if i in g.sym and g.sym[i]['kind'] not in ('library', 'written')}; fr = list(seen) while fr: @@ -1778,8 +1790,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): ids = sorted(seen); rows = [] for i in range(0, len(ids), 900): part = ids[i:i + 900] - rows += g.q("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" - " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)" % ','.join('?' * len(part)), *part) + for sql in value_sql: rows += g.q(sql % ','.join('?' * len(part)), *part) if rows: opaque.append((lx, sorted(rows, key=lambda r: (g.site_file(r['f']), r['ln'] or 0)))) # …but a framework may still connect them, and the graph holds the evidence for it: the target is registered # under a key, and the source writes that key. That is not a chain of calls, so it is reported and not walked. @@ -1821,15 +1832,22 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): 'instance_member': 'a function stored on the instance from outside the class', 'parameter_member': 'a member of a parameter', 'expression': 'computed by an expression', 'getattr': 'an attribute looked up by a name computed at run time', - 'imported_variable': "another module's exported variable, which that module reassigns"} + 'imported_variable': "another module's exported variable, which that module reassigns", + 'function_type': 'held by a field, variable or parameter of a function type, whatever function it is given'} + # A value callee that carries the OTHER endpoint's name (`this.find()` where the field holds a wrapper + # around find) is the likeliest connection: listed first, and said so. + names = {la: {g.sym[i]['name'] for i in B_ if i in g.sym}, lb: {g.sym[i]['name'] for i in A_ if i in g.sym}} for lx, rows in opaque: + other = lb if lx == la else la + rows = sorted(rows, key=lambda r: r['n'] not in names[lx]) # the sites that make the answer unknown, in --json too: a consumer reading only `answers` saw "no chain" RESULT['value_calls'] = RESULT.get('value_calls', []) + [ {'side': lx, 'caller': g.disp(r['c']), 'callee': r['n'] or '', 'at': f"{g.site_file(r['f'])}:{r['ln']}", - 'callee_is': WHY.get(r['why'], r['why'])} for r in rows[:50]] + 'callee_is': WHY.get(r['why'], r['why']), 'named_like_target': r['n'] in names[lx]} for r in rows[:50]] print(f" {lx} reaches {len(rows)} call(s) through a value:") for r in rows[:4]: - print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}") + print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}" + + (f" — named like the target: if it holds {other}, the chain is real" if r['n'] in names[lx] else '')) if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) return 1 diff --git a/tests/cases/javascript/stored-field-callee-shapes/case.json b/tests/cases/javascript/stored-field-callee-shapes/case.json index b17c9107f..12284de97 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/case.json +++ b/tests/cases/javascript/stored-field-callee-shapes/case.json @@ -215,6 +215,81 @@ "avoid": [ "connection is UNKNOWN, not absent" ] + }, + { + "why": "Svc.login calls this.find(), a field holding what a platform call (promisify) returned for a bound project method: the wrapper runs that method, so path says unknown and names the same-named site, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`find()` in Svc.login at src/shapes.js:42 — the callee is a function stored in a field", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the same wrapper held in a module variable, bound to a method the graph resolves: the wrapper runs it, so the chain is real", + "run": [ + "path", + "viaModule", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:44] Store.find" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "a field wrapper whose bound receiver is typed by the argument its constructor is given resolves the same way", + "run": [ + "path", + "Auth.login", + "Store.find" + ], + "want": [ + "→ [multi_inferred · call @ src/shapes.js:45] Store.find" + ], + "avoid": [] + }, + { + "why": "control: a field holding what a platform call returned for a platform function (promisify(setTimeout)) is a platform call", + "run": [ + "path", + "Clock.tick", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a platform call handed a bound PLATFORM function (promisify(Math.max.bind(Math))) is a platform call", + "run": [ + "path", + "Reader.go", + "Store.find" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] } ] } diff --git a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js index 753496438..9dfe4f1ac 100644 --- a/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js +++ b/tests/cases/javascript/stored-field-callee-shapes/src/shapes.js @@ -37,6 +37,15 @@ const tag = require('util').format; function tags() { return tag('%s', 'x'); } const wrapped = debug(alpha); function callsWrapped() { return wrapped(); } +const { promisify } = require('util'); +class Store { find(e) { return e; } } +class Svc { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +const findAsync = promisify(new Store().find.bind(new Store())); +function viaModule(e) { return findAsync(e); } +class Auth { constructor(s) { this.find = promisify(s.find.bind(s)); } login(e) { return this.find(e); } } +function makeAuth() { return new Auth(new Store()); } +class Clock { constructor() { this.wait = promisify(setTimeout); } tick() { return this.wait(1); } } +class Reader { constructor() { this.read = promisify(Math.max.bind(Math)); } go() { return this.read(1); } } // controls: none of these is a value callee const EventEmitter = require('events'); class Bus extends EventEmitter { go() { return this.emit('x'); } } @@ -44,4 +53,4 @@ class Own { own() { return 1; } run() { return this.own(); } } class Known { constructor() { this.fn = alpha; } run() { return this.fn(); } } class Fixed { constructor(cb) { this.cb = alpha || cb; } } function useOwn() { const o = new Own(); return o.run(); } -module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn }; +module.exports = { alpha, FieldNull, CtorNull, Fallback, Static, StaticField, Alias, Maker, logs, tags, callsWrapped, Bus, Known, Fixed, useOwn, Store, Svc, viaModule, Auth, makeAuth, Clock, Reader }; diff --git a/tests/cases/typescript/function-stored-in-a-holder/case.json b/tests/cases/typescript/function-stored-in-a-holder/case.json index bb0e78733..8bb4b2eea 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/case.json +++ b/tests/cases/typescript/function-stored-in-a-holder/case.json @@ -114,6 +114,23 @@ "src/app.ts: " ], "avoid": [] + }, + { + "why": "a call through a function-typed field the graph cannot fill (a library-made wrapper) ends on the type's signature: path says unknown, names the site and that it carries the target's name, never independent", + "run": [ + "path", + "Svc.login", + "Store.find" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "— the callee is held by a field, variable or parameter of a function type", + "named like the target: if it holds Store.find, the chain is real" + ], + "avoid": [ + "the two are independent in this graph" + ] } ] } diff --git a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts index f7a41a80c..29c9a61d7 100644 --- a/tests/cases/typescript/function-stored-in-a-holder/src/app.ts +++ b/tests/cases/typescript/function-stored-in-a-holder/src/app.ts @@ -54,3 +54,17 @@ export function price(registry: Registry, n: number): string { export function checkout(registry: Registry): string { return price(registry, 3) } + +import { promisify } from 'util' + +export class Store { + find(e: string): string { return e } +} + +// A field of a function type holding what a library returned for a bound method: the call resolves to the +// field's signature, which has no body; the wrapped method is what runs. +export class Svc { + private find: (e: string) => Promise + constructor(s: Store) { this.find = promisify(s.find.bind(s)) as any } + login(e: string) { return this.find(e) } +} From 87bf15449b9d8487ffc0231ceca145de69464240 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:05:21 -0700 Subject: [PATCH 5/7] path, impact: a file:line inside a type's body but in none of its members is refused, naming the members either side A line number copied before an edit often lands on a blank or doc-comment line between two methods. Where a class body has no module node of its own (TypeScript, JavaScript, Java, C#), that line fell through to the file's module and was answered as its top-level code, with nothing saying the line held no declaration; Java said only "no callable spans". It is now refused with the enclosing type and the nearest declaration above and below it. A Python class body is a module node inside the type and answers as before. Checked: new checks in fileline-line-outside-file (JS) and fileline-dotted-basename (TS) fail before (3 of 32) and pass after (32 of 32); graph unchanged (9988 call edges on a real NestJS repo either way). --- .../skills/axiomcode/scripts/axiomcode-path | 16 +++++++++++++++ .../fileline-line-outside-file/case.json | 13 ++++++++++++ .../src/services/cart.js | 8 ++++++++ .../fileline-dotted-basename/case.json | 20 ++++++++++++++++++- .../fileline-dotted-basename/src/cart.ts | 7 +++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/cases/javascript/fileline-line-outside-file/src/services/cart.js create mode 100644 tests/cases/typescript/fileline-dotted-basename/src/cart.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 3fbea52f1..4f92d6121 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -406,6 +406,22 @@ class G: # body's), and the first row came back whatever the line, so a module-level constant below a class was # answered as that class's body r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL ORDER BY (line <= ? AND COALESCE(end_line, line) >= ?) DESC, COALESCE(end_line, line) - line LIMIT 1", f, ln, ln) + # A LINE INSIDE A TYPE'S BODY BUT IN NONE OF ITS MEMBERS is not top-level code. A blank or comment line between + # two methods — a line number copied before the file was edited — was answered as the whole file's module + # where a class body has no module node of its own (TypeScript, Java, C#), with no word that the line holds + # nothing. A Python class body IS a module node inside the type, and still answers as before. + ty = self.q("SELECT id, display, line, end_line FROM symbols WHERE file = ? AND type_id IS NOT NULL AND method_id IS NULL AND line < ? AND end_line > ? ORDER BY end_line - line LIMIT 1", f, ln, ln) + mod = self.sym.get(r[0]['id'], {}) if r else {} + if ty and not (r and ty[0]['line'] <= (mod.get('line') or 0) and (mod.get('end_line') or 0) <= ty[0]['end_line']): + t = ty[0] + mem = self.q("SELECT id, line, end_line FROM symbols WHERE file = ? AND line > ? AND end_line < ? AND kind <> 'module' AND (method_id IS NOT NULL OR kind IN ('field','const','enum_member','variable'))", f, t['line'], t['end_line']) + above = max((x for x in mem if (x['end_line'] or x['line']) < ln), key=lambda x: (x['end_line'] or x['line'], -x['line']), default=None) + below = min((x for x in mem if x['line'] > ln), key=lambda x: (x['line'], x['line'] - (x['end_line'] or x['line'])), default=None) + near = [f" {self.name(x['id'])} {f}:{x['line']}" for x in (above, below) if x] + die(f"line {ln} of {f} is inside {t['display']} ({f}:{t['line']}-{t['end_line']}) but in none of its declarations" + " (a blank, comment or separator line — often a line number from before an edit)." + + ("\n the nearest declarations:\n" + '\n'.join(near) if near else '') + + f"\n ask for one of them, by name or by its line; `{t['display']}` asks about the whole type") if r: return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] if outside: die(f"{m.group(1)} is outside the indexed repository {self.repo}: give the file relative to that root, or ask the graph of the repository it belongs to") die(f"no callable spans {s}") diff --git a/tests/cases/javascript/fileline-line-outside-file/case.json b/tests/cases/javascript/fileline-line-outside-file/case.json index 2dae3ef39..7e0cde15a 100644 --- a/tests/cases/javascript/fileline-line-outside-file/case.json +++ b/tests/cases/javascript/fileline-line-outside-file/case.json @@ -36,6 +36,19 @@ "run": ["path", "*", "src/services/userService.js:2"], "want": ["getUser"], "avoid": ["is not in it"]}, + {"why": "a blank line between two members of a class is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/services/cart.js:3"], + "expect_error": true, + "want": ["line 3 of src/services/cart.js is inside Cart", "Cart.add src/services/cart.js:5"], + "avoid": [""]}, + {"why": "control: the class header line still answers for the class", + "run": ["impact", "src/services/cart.js:1"], + "want": ["Cart"], + "avoid": ["is inside Cart", "change: cart."]}, + {"why": "control: the line after the class is still the file's top-level code", + "run": ["impact", "src/services/cart.js:7"], + "want": ["cart. (at src/services/cart.js:7)"], + "avoid": ["is inside Cart"]}, {"why": "control: a file the index does not hold is still refused as before", "run": ["impact", "zzz/userService.js:99"], "expect_error": true, diff --git a/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js new file mode 100644 index 000000000..159f3f1b3 --- /dev/null +++ b/tests/cases/javascript/fileline-line-outside-file/src/services/cart.js @@ -0,0 +1,8 @@ +class Cart { + constructor() { this.items = []; } + + /** adds one */ + add(x) { this.items.push(x); } +} +new Cart().add('a'); +module.exports = { Cart }; diff --git a/tests/cases/typescript/fileline-dotted-basename/case.json b/tests/cases/typescript/fileline-dotted-basename/case.json index 701c6d748..7efe90b68 100644 --- a/tests/cases/typescript/fileline-dotted-basename/case.json +++ b/tests/cases/typescript/fileline-dotted-basename/case.json @@ -23,4 +23,22 @@ {"why": "path takes a ./ file:line", "run": ["path", "*", "./src/user.service.ts:3"], "want": ["UserController.get"], - "avoid": ["no callable spans"]}]} + "avoid": ["no callable spans"]}, + {"why": "a blank line inside a class body, between two members, is refused with the members either side, not answered as the file's top-level code", + "run": ["impact", "src/cart.ts:3"], + "expect_error": true, + "want": ["line 3 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": [""]}, + {"why": "the same for a member's doc-comment line, through path", + "run": ["path", "*", "src/cart.ts:4"], + "expect_error": true, + "want": ["line 4 of src/cart.ts is inside Cart", "Cart.add src/cart.ts:5"], + "avoid": ["top-level code"]}, + {"why": "control: the member's own line still answers for it", + "run": ["impact", "src/cart.ts:5"], + "want": ["change: Cart.add (at src/cart.ts:5)"], + "avoid": ["is inside Cart"]}, + {"why": "control: a line after the class is still the file's top-level code", + "run": ["impact", "src/cart.ts:7"], + "want": ["cart. (at src/cart.ts:7)"], + "avoid": ["is inside Cart"]}]} diff --git a/tests/cases/typescript/fileline-dotted-basename/src/cart.ts b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts new file mode 100644 index 000000000..8b0c3aa94 --- /dev/null +++ b/tests/cases/typescript/fileline-dotted-basename/src/cart.ts @@ -0,0 +1,7 @@ +export class Cart { + private items: string[] = []; + + /** adds one */ + add(x: string): void { this.items.push(x); } +} +new Cart().add('a'); From 57458ad43f503fbef64803198c94a411e45ab5c4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:01:25 -0700 Subject: [PATCH 6/7] engine: a function wrapped by a library call and kept in a const is registered where the const is handed over TypeScript: a const whose initializer is a boundary call handed a function (fp(async (app) => ...), defineExtension(fn)) hands that function on to a library registration it is passed to, by name or through an import. Not a reassignment, not a call through a project value, not a call whose typed result has no call signature. JavaScript: the same, for a call rooted at a package import, carried as its own value kind that is never a callee. path answered 'independent' for a plugin registered this way although the bare form was reached. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/callbacks.dl | 28 +++++++++++++ .../callee-resolution.dl | 2 +- graph/javascript/souffle/decls_all.dl | 4 ++ .../34-options-object-callbacks/src/main.js | 6 +++ .../expected/34-options-object-callbacks.diag | 4 ++ .../34-options-object-callbacks.edges | 7 ++++ .../34-options-object-callbacks.lib.diag | 2 + .../34-options-object-callbacks.lib.edges | 7 ++++ .../34-options-object-callbacks.oracle | 1 + .../70-single-file-component-scripts.edges | 1 + .../src/app.ts | 19 +++++++++ .../src/plugin.ts | 12 ++++++ .../78-hof-callback-at-library-boundary.edges | 10 +++++ ...8-hof-callback-at-library-boundary.entries | 5 ++- ...78-hof-callback-at-library-boundary.oracle | 2 +- .../engine/resolution/value-flow.dl | 28 +++++++++++++ graph/typescript/souffle/decls_all.dl | 3 ++ .../case.json | 40 +++++++++++++++++++ .../src/app.ts | 19 +++++++++ .../src/plugin.ts | 12 ++++++ 20 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts diff --git a/graph/javascript/engine/call-edge-generation/callbacks.dl b/graph/javascript/engine/call-edge-generation/callbacks.dl index 107842dbd..6dbc84000 100644 --- a/graph/javascript/engine/call-edge-generation/callbacks.dl +++ b/graph/javascript/engine/call-edge-generation/callbacks.dl @@ -66,6 +66,34 @@ options_value_kind("obj"). call_has_client_target(ce) :- expr_resolves_to_method(ce, m), method_prov(m, "client"). call_has_client_target(ce) :- new_constructs(ce, t), type_decl("client", _, _, _, _, t). +// A function WRAPPED BY A PACKAGE CALL and kept in a binding: `export const plugin = fp(async (app) => …)`, +// `const ext = Prisma.defineExtension((client) => …)`, then `app.register(plugin)`, `client.$extends(ext)`. +// The package returns the function it was handed, or one that runs it, and nothing in the project says +// which, so the binding had no value and the registration reached nothing, although the same literal +// handed to it bare is registered. The wrapping site is a value of its own, ("libwrap", site), carried +// wherever a value goes (a const, an import, an export), and a registration handed it registers what the +// site was handed. The site is recognised syntactically, by a callee rooted at a binding imported from a +// package, so the value stays below the resolver's negations; a project wrapper is followed through its +// body instead (value-flow.dl, "wrap"). +lib_rooted(e) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + import_decl(_, _, _, _, _, _, out, _, imp), import_outcome_is_package(out). +lib_rooted(e) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_child(_, e, "ACCESS_TARGET", _, r), lib_rooted(r). +import_outcome_is_package("RESOLVED_EXTERNAL"). +import_outcome_is_package("UNRESOLVED_MISSING"). +access_kind_reads_member("PROPERTY_ACCESS"). +access_kind_reads_member("OPTIONAL_ACCESS"). +lib_wrap_site(s) :- call_site(_, ck, _, _, _, _, s, _, _), call_kind_is_callee_form(ck), + expr_child(_, s, "CALLEE", _, c), lib_rooted(c). +lib_wrap_site(s) :- call_site(_, ck, _, "SYNTACTIC", _, _, s, _, _), call_kind_is_member_form(ck), + expr_child(_, s, "RECEIVER", _, r), lib_rooted(r). +// Handed over by NAME: `register(wrap(f))` needs nothing new, since the inner site already registers f +// from the same caller. The value is not a callee (callee-resolution.dl): what a call of it runs is still +// unknown, and says so. +expr_value(s, "libwrap", s) :- lib_wrap_site(s), call_arg(s, _, a), expr_value(a, "func", _). +callback_registered(ce, m) :- invocation_site(ce, _), !call_has_client_target(ce), !reflective_site(ce), + call_arg(ce, _, arg), !expr_kind(_, "CALL", _, arg), + expr_value(arg, "libwrap", s), call_arg(s, _, a), expr_value(a, "func", m). + // A listener runs with the EMITTER as `this` (`e.on('x', function () { this.other(); })`). this_value(m, k, i) :- event_handler(k, i, _, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), k != "module". diff --git a/graph/javascript/engine/expression-resolution/callee-resolution.dl b/graph/javascript/engine/expression-resolution/callee-resolution.dl index c41600158..946d7cb34 100644 --- a/graph/javascript/engine/expression-resolution/callee-resolution.dl +++ b/graph/javascript/engine/expression-resolution/callee-resolution.dl @@ -27,7 +27,7 @@ // ── callee_value(CallExpr, K, I) — the value being invoked ───────────────── callee_value(ce, k, i) :- call_site(_, ck, _, _, _, _, ce, _, _), call_kind_is_callee_form(ck), - expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i). + expr_child(_, ce, "CALLEE", _, c), expr_value(c, k, i), k != "libwrap". # what a package wrapper returns runs nothing known (callbacks.dl) // `f.call(o)`: f runs. But the parser classifies by NAME, so `selector.apply(node)` // on an object with its own `apply` method is here too — and for that reading the // CALLEE child (the object) is the receiver and its member is the target. Both diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17b..902a468c8 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -451,6 +451,10 @@ .decl options_object_member(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl options_value_kind(c0:symbol) .decl call_has_client_target(c0:symbol) +.decl lib_rooted(c0:symbol) +.decl import_outcome_is_package(c0:symbol) +.decl access_kind_reads_member(c0:symbol) +.decl lib_wrap_site(c0:symbol) .decl callback_registered(c0:symbol, c1:symbol) .decl event_handler(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl event_register_method(c0:symbol) diff --git a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js index 0a29fdf73..baabbc0fc 100644 --- a/graph/test/javascript/cases/34-options-object-callbacks/src/main.js +++ b/graph/test/javascript/cases/34-options-object-callbacks/src/main.js @@ -14,3 +14,9 @@ function viaCtor() { return new Transform({ transform }); } function viaProject() { return localWalk([1], { filter: keep }); } function main() { direct(); viaVar(); viaPlatform(); viaCtor(); viaProject(); } main(); +// a function wrapped by a package call and kept in a const, then handed to a package registration: registered +function migrate() { return 1; } +const plugin = walk(async () => migrate()); +const settings = walk(42); +function viaWrapped() { walk.register(plugin); walk.register(settings); } +module.exports = { viaWrapped }; diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index f261bb398..2937e0b0a 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -8,6 +8,10 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:19:16 FUNCTION_CALL walk callee_untyped +unresolved main.js:20:18 FUNCTION_CALL walk callee_untyped +unresolved main.js:21:25 METHOD_CALL register receiver_untyped +unresolved main.js:21:48 METHOD_CALL register receiver_untyped unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.edges b/graph/test/javascript/expected/34-options-object-callbacks.edges index 04390e7a1..2336e81fe 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag index 99d2f7ba0..72bf6bbbc 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -6,6 +6,8 @@ unresolved main.js:12:86 METHOD_CALL destroy no_target unresolved main.js:12:86 METHOD_CALL on no_target unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target +unresolved main.js:21:25 METHOD_CALL register member_absent +unresolved main.js:21:48 METHOD_CALL register member_absent unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges index f5582b137..10163a112 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.edges +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.edges @@ -18,6 +18,13 @@ main.js:15:39 FUNCTION_CALL viaPlatform -> known_edge main.js:12:1 viaPlatfor main.js:15:54 FUNCTION_CALL viaCtor -> known_edge main.js:13:1 viaCtor main.js:15:65 FUNCTION_CALL viaProject -> known_edge main.js:14:1 viaProject main.js:16:1 FUNCTION_CALL main -> known_edge main.js:15:1 main +main.js:19:16 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:19:16 FUNCTION_CALL walk -> callback_registered main.js:19:21 +main.js:19:33 FUNCTION_CALL migrate -> known_edge main.js:18:1 migrate +main.js:20:18 FUNCTION_CALL walk -> boundary_lib lib:index.js:2:1 walk +main.js:21:25 METHOD_CALL walk.register -> ambiguous_unknown - +main.js:21:25 METHOD_CALL walk.register -> callback_registered main.js:19:21 +main.js:21:48 METHOD_CALL walk.register -> ambiguous_unknown - main.js:8:38 FUNCTION_CALL cb -> ambiguous_unknown - main.js:9:42 METHOD_CALL items.map -> ambient_terminal - main.js:9:42 METHOD_CALL items.map -> callback_registered main.js:9:52 diff --git a/graph/test/javascript/expected/34-options-object-callbacks.oracle b/graph/test/javascript/expected/34-options-object-callbacks.oracle index be68d1524..f31f7ea51 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.oracle +++ b/graph/test/javascript/expected/34-options-object-callbacks.oracle @@ -5,4 +5,5 @@ main.js:15:39 FUNCTION_CALL viaPlatform EXACT main.js:12:1 main.js:15:54 FUNCTION_CALL viaCtor EXACT main.js:13:1 main.js:15:65 FUNCTION_CALL viaProject EXACT main.js:14:1 main.js:16:1 FUNCTION_CALL main EXACT main.js:15:1 +main.js:19:33 FUNCTION_CALL migrate EXACT main.js:18:1 # defects: 0 diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.edges b/graph/test/javascript/expected/70-single-file-component-scripts.edges index 0bb4c4cbb..21bf8d411 100644 --- a/graph/test/javascript/expected/70-single-file-component-scripts.edges +++ b/graph/test/javascript/expected/70-single-file-component-scripts.edges @@ -7,6 +7,7 @@ components/List.svelte:5:39 FUNCTION_CALL fetchProducts -> known_edge lib/for components/List.svelte:6:29 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice components/Price.vue:1:25 FUNCTION_CALL onClick -> known_edge components/Price.vue:8:1 onClick components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> ambiguous_unknown - +components/Price.vue:1:50 FUNCTION_CALL onlyInMarkup -> callback_registered components/Price.vue:7:24 components/Price.vue:6:15 FUNCTION_CALL defineProps -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> ambiguous_unknown - components/Price.vue:7:15 FUNCTION_CALL computed -> callback_registered components/Price.vue:7:24 diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts index 198ebefae..10e32c63f 100644 --- a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 000000000..3f31a5673 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42) diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges index 4c3712657..52529f7bd 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -1,14 +1,24 @@ ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +ambient_terminal FUNCTION_CALL plugin#() @L12 -> plugin#wrap(T) +ambient_terminal FUNCTION_CALL plugin#() @L7 -> plugin#wrap(T) callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered FUNCTION_CALL plugin#() @L7 -> plugin#() +callback_registered METHOD_CALL app#boot() @L57 -> plugin#() +callback_registered METHOD_CALL app#bootBare() @L62 -> app#() callback_registered METHOD_CALL app#doubled(number[]) @L16 -> app#(?) callback_registered METHOD_CALL app#named(number[]) @L25 -> app#double(number) callback_registered METHOD_CALL app#scaled(number[]) @L12 -> app#(?) callback_registered METHOD_CALL app#viaLocal(number[]) @L31 -> app#log(number) known_edge FUNCTION_CALL app#() @L38 -> app#record(number) known_edge FUNCTION_CALL app#(?) @L48 -> app#record(number) +known_edge FUNCTION_CALL app#() @L62 -> plugin#migrate() known_edge FUNCTION_CALL app#each(number[],(n: number) =) @L44 -> app#(number) known_edge FUNCTION_CALL app#log(number) @L30 -> app#record(number) known_edge FUNCTION_CALL app#useEach(number[]) @L48 -> app#each(number[],(n: number) =) +known_edge FUNCTION_CALL plugin#() @L8 -> plugin#migrate() +known_edge METHOD_CALL app#boot() @L57 -> app#register(unknown) +known_edge METHOD_CALL app#bootBare() @L62 -> app#register(unknown) +known_edge METHOD_CALL app#bootSettings() @L67 -> app#register(unknown) known_edge METHOD_CALL app#doubled(number[]) @L16 -> Array#map((v: T) =) known_edge METHOD_CALL app#named(number[]) @L25 -> Array#map((v: T) =) known_edge METHOD_CALL app#scaled(number[]) @L12 -> Array#map((v: T) =) diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries index 5c3d7421a..79860ca6f 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -1,5 +1,8 @@ -── entry_point (9) ── +── entry_point (12) ── exported_from_entry_module app#atStartup app.ts:37 + exported_from_entry_module app#boot app.ts:56 + exported_from_entry_module app#bootBare app.ts:61 + exported_from_entry_module app#bootSettings app.ts:66 exported_from_entry_module app#doubled app.ts:15 exported_from_entry_module app#each app.ts:43 exported_from_entry_module app#named app.ts:24 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle index f31a2b293..e6f0ab28d 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -1 +1 @@ -oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 +oracle=16 engine=16 agree=16 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index b8c6aba61..439dac718 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -236,6 +236,34 @@ handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUM value_branch(a, x), method_value(x, m). +// …AND WHAT A LIBRARY CALL WRAPPED, KEPT IN A HOLDER. `export const plugin = fp(async (app) => …)`, then +// `app.register(plugin)`: the holder keeps what a call with no client body RETURNED, so no holder rule above +// sees a function in it, and the registration reached nothing although the same literal handed to it bare +// is reached. A library wrapper hands back the function it was handed, or one that runs it (`fp`, +// `defineExtension`, `debounce`), so the function the wrapping site was handed is what the holder hands on. +// Only a wrapping site at the library boundary: a project wrapper has a body, and what it returns is +// followed through that body (fn_value_call, below). The const is named here or imported from the +// module that wraps it (expr_holder), which is the usual shape: a plugin module, a registering app. +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_holder(x, h), + holder_wraps_handed_function(h, m). +// A const's own initializer only, and only a call of a declaration, not of a value the project holds: a +// reassignment (`state = createState(set, get)`) and a call through a parameter hand back what the project +// function returns, which is data as often as it is the function it was handed. +// And not a call whose result the types say is data: `setTimeout(cb)` returns a timer, `xs.filter(cb)` an +// array, and `clearTimeout(timer)` runs nothing. A result typed with no call signature is data; an untyped +// one (a package with no types staged) or a callable one (`fp`'s plugin type, a mock) is kept. +holder_wraps_handed_function(h, m) :- var_initializer("client", _, e, h), e != "", + value_branch(e, w), + expr_kind("client", "CALL_EXPRESSION", _, w), + hof_boundary_site(w), + !called_through(w, _), + !call_returns_data(w), + handed_function(w, m). +call_returns_data(w) :- expr_type(w, _, t), !call_signature_in_scope(t, _), !call_result_callable(w). +call_result_callable(w) :- expr_shape(w, s), call_signature_in_scope(s, _). + // ── method_value(Expr, Method): an INSTANCE method read as a value, not called ── // `xs.forEach(this.handle, this)`, `el.addEventListener('click', this.onClick)`, // `setTimeout(this.onHover.bind(this))`. expr_callable names a free function, an import and diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb37..deb07ceae 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -216,6 +216,9 @@ .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl holder_wraps_handed_function(c0:symbol,c1:symbol) // (holder, fn): the holder keeps what a library call returned, and fn was handed to that call +.decl call_returns_data(c0:symbol) // the call's result is typed, and the type has no call signature +.decl call_result_callable(c0:symbol) .decl method_value(c0:symbol,c1:symbol) .decl method_is_accessor(c0:symbol) .decl called_through(c0:symbol,c1:symbol) diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/case.json b/tests/cases/typescript/hof-callback-at-library-boundary/case.json index 4f1d456b7..d59b77d3f 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/case.json +++ b/tests/cases/typescript/hof-callback-at-library-boundary/case.json @@ -61,6 +61,46 @@ "no chain of resolved calls" ] }, + { + "why": "a function literal wrapped by a library call and kept in a const (`const plugin = wrap(async () => …)`), then handed to a library registration (`host.register(plugin)`), is reached from the function that registers it, as the literal handed bare is; it was 'the two are independent in this graph'", + "run": [ + "path", + "boot", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ], + "avoid": [ + "the two are independent" + ] + }, + { + "why": "CONTROL: the same literal handed to the registration bare is reached as before", + "run": [ + "path", + "bootBare", + "migrate" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ] + }, + { + "why": "CONTROL: a const holding what a library call built from a plain value registers no function", + "run": [ + "path", + "bootSettings", + "migrate" + ], + "want": [], + "avoid": [ + "1 of 1 target(s) reached" + ], + "expect_error": true + }, { "why": "CONTROL: a project higher-order function has a body that calls its parameter, so the callback it is handed is still reached through that call", "run": [ diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts index 198ebefae..10e32c63f 100644 --- a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts @@ -47,3 +47,22 @@ export function each(xs: number[], fn: (n: number) => void): void { export function useEach(xs: number[]): void { each(xs, (n) => record(n + RATE)) } + +// a host registration with no body: the const handed to it holds a library-wrapped function literal, and +// registering the const reaches that literal as registering the literal bare does +import { migrate, plugin, settings } from './plugin' +declare const host: { register(p: unknown): void } + +export function boot(): void { + host.register(plugin) +} + +// CONTROL: the literal handed bare, already reached from the site that hands it +export function bootBare(): void { + host.register(async () => migrate()) +} + +// CONTROL: a const with no function in it registers nothing +export function bootSettings(): void { + host.register(settings) +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts new file mode 100644 index 000000000..3f31a5673 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/plugin.ts @@ -0,0 +1,12 @@ +// A FUNCTION WRAPPED BY A LIBRARY CALL AND KEPT IN A CONST. `wrap` is a declaration only, so the const holds +// what a library returns; the function literal it was handed is what a registration of the const runs. +declare function wrap(f: F): F + +export function migrate(): void {} + +export const plugin = wrap(async () => { + migrate() +}) + +// CONTROL: a const holding a plain value built by a library call hands over no function +export const settings = wrap(42) From c05607c2fa7e1c0e1d70a09f56e97aef23f13281 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:22:12 -0700 Subject: [PATCH 7/7] engine: a bound function carries its bound this, and promisify and partial are transparent - javascript: f.bind(o) gives f's this the value of o (objects and instances, functions no class owns), so this.repo.append() inside a handler bound to { repo } resolves - javascript: util.promisify(f) evaluates to what f holds, recognised on the import of the core util module; a project's own promisify is untouched - typescript: a declared this parameter types this inside the function; the compiler oracle's labels drop it, as the engine's do - python: a functools.partial call denotes its target wherever it goes (attribute, argument), not only in a local name Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/engine/resolution/ambient.dl | 20 ++++++++ .../engine/resolution/value-flow.dl | 17 +++++++ graph/javascript/souffle/decls_all.dl | 4 ++ graph/python/engine/resolution/value-flow.dl | 15 +++--- .../73-bound-function-identity/src/bound.js | 50 +++++++++++++++++++ .../expected/73-bound-function-identity.diag | 11 ++++ .../expected/73-bound-function-identity.edges | 35 +++++++++++++ .../73-bound-function-identity.oracle | 15 ++++++ .../python/cases/08-callables/src/main.py | 10 +++- graph/test/python/expected/08-callables.edges | 4 ++ graph/test/python/expected/08-callables.tiers | 30 +++++------ .../cases/84-declared-this-parameter/src/x.ts | 17 +++++++ .../expected/84-declared-this-parameter.edges | 9 ++++ .../84-declared-this-parameter.entries | 3 ++ .../84-declared-this-parameter.fields | 4 ++ .../84-declared-this-parameter.fields-oracle | 9 ++++ .../84-declared-this-parameter.oracle | 1 + .../84-declared-this-parameter.type-use | 6 +++ .../84-declared-this-parameter.types-oracle | 7 +++ .../typescript/ground-truth/tsc-program.mjs | 5 +- .../engine/expression-resolution/expr-type.dl | 10 ++++ graph/typescript/souffle/decls_all.dl | 2 + 22 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 graph/test/javascript/cases/73-bound-function-identity/src/bound.js create mode 100644 graph/test/javascript/expected/73-bound-function-identity.diag create mode 100644 graph/test/javascript/expected/73-bound-function-identity.edges create mode 100644 graph/test/javascript/expected/73-bound-function-identity.oracle create mode 100644 graph/test/typescript/cases/84-declared-this-parameter/src/x.ts create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.edges create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.entries create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.fields create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.fields-oracle create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.oracle create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.type-use create mode 100644 graph/test/typescript/expected/84-declared-this-parameter.types-oracle diff --git a/graph/javascript/engine/resolution/ambient.dl b/graph/javascript/engine/resolution/ambient.dl index 3f0d8ebe2..7d321c896 100644 --- a/graph/javascript/engine/resolution/ambient.dl +++ b/graph/javascript/engine/resolution/ambient.dl @@ -73,6 +73,26 @@ modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_ expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "Object", recv). modelled_platform_call(ce) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "_extend", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_kind(_, "IDENTIFIER", _, recv), expr_text(_, "util", recv). +// `promisify(f)` is f behind a transparent wrapper (value-flow.dl): its value is what f holds. +modelled_platform_call(ce) :- promisify_call(ce). +// promisify_call(Call): the core `util` module's `promisify`, as a name imported from it +// (`const { promisify } = require('util')`, `import { promisify } from 'node:util'`) or as +// a member of it (`util.promisify(f)` with `util` bound to the module, `require('util').promisify(f)`). +// Recognised on the import rows and the binder, never by the name alone: a project's own +// `promisify` is an ordinary function and resolves as one. +promisify_call(ce) :- call_site(_, "FUNCTION_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_binding(_, v, c), var_import(_, imp, v), + import_decl(_, spec, _, _, "promisify", _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_binding(_, v, r), var_import(_, imp, v), + import_decl(_, spec, _, bf, _, _, "RESOLVED_BUILTIN", _, imp), import_binds_whole_module(bf), util_module(spec). +promisify_call(ce) :- call_site(_, "METHOD_CALL", "promisify", _, _, _, ce, _, _), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "MODULE_EDGE_CALL", _, r), expr_module_edge(_, imp, r), + import_decl(_, spec, _, _, _, _, "RESOLVED_BUILTIN", _, imp), util_module(spec). +import_binds_whole_module("DEFAULT"). +import_binds_whole_module("NAMESPACE"). +util_module("util"). +util_module("node:util"). // The Object statics whose result frameworks.dl gives a value of its own. modelled_object_method("assign"). modelled_object_method("create"). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 15379c699..d6f8c1323 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -155,6 +155,11 @@ expr_value(e, k, i) :- expr_kind(_, "NEW", _, e), new_callee_value(e, "func", m) // (callee-resolution.dl); the VALUE of the expression is f as well. expr_value(e, "func", m) :- expr_kind(_, "CALL", _, e), call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, e, _, _), expr_child(_, e, "CALLEE", _, c), expr_value(c, "func", m). +// `promisify(f)` evaluates to a function that runs f (with a callback appended), so a call +// through it reaches f: the wrapper is transparent. What the argument holds is what the +// result holds, so `promisify(fs.readFile)` stays the platform's and an unknown argument +// stays unknown. ambient.dl keeps the platform's own value off it (modelled_platform_call). +expr_value(ce, k, i) :- promisify_call(ce), call_arg(ce, 0, a), expr_value(a, k, i). // Transparent wrappers: `await x`, `(c ? a : b)`, `a || b`, `a && b`, `a ?? b`, // `x = v` (an assignment expression evaluates to v), `(a, b)`. expr_value(e, k, i) :- expr_kind(_, "AWAIT", _, e), expr_child(_, e, _, _, c), expr_value(c, k, i). @@ -330,6 +335,18 @@ this_value(m, k, i) :- method_this_binding(_, "LEXICAL", m), method_enclosing(m, this_value(m, "obj", l) :- expr_child(_, l, "PROPERTY_VALUE", _, v), expr_kind(_, "OBJECT_LITERAL", _, l), expr_introduces(_, m, v), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). this_value(m, "obj", l) :- literal_owns_method(l, m), method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _). +// `f.bind(o)` fixes f's `this` to o for every call through what it returns, wherever that +// value travels: `onEvent.bind({ repo })` then `this.repo.append()` inside onEvent. The +// thisArg is the site's RECEIVER child. Only objects and instances, and only a function no +// class owns (a member's `this` is its instance already): `bind(null)` and a primitive +// bind nothing, and a class member re-bound to its own instance adds nothing new. +// `f.call(o)` / `f.apply(o)` are not read here: they run f once at that site. +this_value(m, k, i) :- call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), + expr_child(_, ce, "CALLEE", _, c), expr_value(c, "func", m), + method_this_binding(_, "DYNAMIC", m), !method_owner_type(m, _), + expr_child(_, ce, "RECEIVER", _, o), expr_value(o, k, i), bound_this_kind(k). +bound_this_kind("obj"). +bound_this_kind("inst"). // `T.prototype.constructor = T` is a BACK-REFERENCE, not an installation: nothing // calls it with the prototype as `this`, and reading it as one gave a constructor // the `Object.create(Base.prototype)` object as `this` (#708). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 1f91eb17b..ba441ed72 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -300,6 +300,10 @@ .decl well_known_symbol_read(c0:symbol) .decl modelled_platform_call(c0:symbol) .decl modelled_object_method(c0:symbol) +.decl promisify_call(c0:symbol) +.decl util_module(c0:symbol) +.decl import_binds_whole_module(c0:symbol) +.decl bound_this_kind(c0:symbol) .decl free_namespace(c0:symbol, c1:symbol) .decl ts_export_star(c0:symbol, c1:symbol) .decl ts_export_star_helper(c0:symbol) diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 5e96f8689..230c1f1bf 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -329,14 +329,13 @@ partial_target(site, m) :- call_arg(site, "0", a), expr_denotes_method("client", a, m). -// ── a name holding a partial denotes the partial's target ──────────────────── -binding_value_method(p, b, m) :- - binding_rebinding(p, "1", _, _, b), - expr_binding(p, b, "STORE", tgt), - assign_pair(p, tgt, val), - call_of_expr(val, site), - partial_target(site, m), - p = "client". +// ── a partial denotes the partial's target ─────────────────────────────────── +// The call EXPRESSION, so wherever the partial goes the target goes with it: a name +// (`add_ten = partial(add, 10)`, through (d)'s alias rule), an attribute +// (`self.op = partial(repo.append)`, through field_holds_method), an argument. +expr_denotes_method("client", e, m) :- + call_of_expr(e, site), + partial_target(site, m). // ── param_arg_class(ParamHash, TypeHash) — a CLASS OBJECT reaching a parameter ─ // Distinct from param_arg_type on purpose: param_arg_type means "an INSTANCE of this diff --git a/graph/test/javascript/cases/73-bound-function-identity/src/bound.js b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js new file mode 100644 index 000000000..40374a112 --- /dev/null +++ b/graph/test/javascript/cases/73-bound-function-identity/src/bound.js @@ -0,0 +1,50 @@ +'use strict'; +const { promisify } = require('util'); + +class Repo { + append(x) { return x; } + find(k) { return k; } +} +class Other { + append(x) { return x; } +} + +// (a) a field or variable assigned from x.m.bind(x) is x.m +class Service { + constructor(r) { + this.repo = r; + this.add = this.repo.append.bind(this.repo); + this.lookup = promisify(r.find.bind(r)); + } + go() { return this.add(1); } + get(k) { return this.lookup(k); } +} +function makeService() { return new Service(new Repo()); } +const repo = new Repo(); +const bound = repo.append.bind(repo); +function viaVariable() { return bound(2); } +const wrapped = promisify(repo.find.bind(repo)); +function viaPromisified() { return wrapped('k'); } +const util = require('util'); +const viaNamespace = util.promisify(repo.find.bind(repo)); +function viaNamespaceCall() { return viaNamespace('n'); } + +// (b) a function bound to an object literal sees its keys through `this` +function onEvent(e) { return this.repo.append(e); } +const handler = onEvent.bind({ repo: new Repo() }); +function fire() { return handler(1); } +const handlers = { + created: function onCreated(e) { return this.store.append(e); }, +}; +const onCreatedBound = handlers.created.bind({ store: new Other() }); + +// controls: none of these changes +function unbound(e) { return this.repo.append(e); } +function callsUnbound() { return unbound.call({ repo: new Repo() }, 1); } +const snapshot = repo.find.bind(null); +function openBind(fn) { const g = fn.bind(repo); return g(); } +const own = { promisify(f) { return () => f; } }; +const notUtil = own.promisify(repo.find); +function viaOwnPromisify() { return notUtil(); } + +module.exports = { makeService, viaVariable, viaPromisified, fire, onCreatedBound, callsUnbound, snapshot, openBind, viaNamespaceCall, viaOwnPromisify }; diff --git a/graph/test/javascript/expected/73-bound-function-identity.diag b/graph/test/javascript/expected/73-bound-function-identity.diag new file mode 100644 index 000000000..fcdd22f91 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.diag @@ -0,0 +1,11 @@ +import_cause bound.js:28:14 util builtin +import_cause bound.js:2:9 util builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved bound.js:17:19 FUNCTION_CALL promisify no_target +unresolved bound.js:26:17 FUNCTION_CALL promisify no_target +unresolved bound.js:29:22 METHOD_CALL promisify no_target +unresolved bound.js:42:30 METHOD_CALL append receiver_untyped +unresolved bound.js:45:35 FUNCTION_CALL_BIND fn callee_untyped +unresolved bound.js:45:57 FUNCTION_CALL g callee_untyped +value_callee bound.js:45:35 fn.bind parameter +value_callee bound.js:45:57 g local diff --git a/graph/test/javascript/expected/73-bound-function-identity.edges b/graph/test/javascript/expected/73-bound-function-identity.edges new file mode 100644 index 000000000..6cab99516 --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.edges @@ -0,0 +1,35 @@ +bound.js:16:16 FUNCTION_CALL_BIND this.repo.append.bind -> known_edge bound.js:5:3 append +bound.js:17:19 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:17:19 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:17:29 FUNCTION_CALL_BIND r.find.bind -> known_edge bound.js:6:3 find +bound.js:19:17 METHOD_CALL this.add -> known_edge bound.js:5:3 append +bound.js:20:19 METHOD_CALL this.lookup -> known_edge bound.js:6:3 find +bound.js:22:33 CONSTRUCTOR_CALL Service -> known_edge bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:23:14 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:24:15 FUNCTION_CALL_BIND repo.append.bind -> known_edge bound.js:5:3 append +bound.js:25:33 FUNCTION_CALL bound -> known_edge bound.js:5:3 append +bound.js:26:17 FUNCTION_CALL promisify -> ambient_terminal - +bound.js:26:17 FUNCTION_CALL promisify -> callback_registered bound.js:6:3 find +bound.js:26:27 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:27:36 FUNCTION_CALL wrapped -> known_edge bound.js:6:3 find +bound.js:29:22 METHOD_CALL util.promisify -> ambient_terminal - +bound.js:29:22 METHOD_CALL util.promisify -> callback_registered bound.js:6:3 find +bound.js:29:37 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:30:38 FUNCTION_CALL viaNamespace -> known_edge bound.js:6:3 find +bound.js:33:30 METHOD_CALL this.repo.append -> known_edge bound.js:5:3 append +bound.js:34:17 FUNCTION_CALL_BIND onEvent.bind -> known_edge bound.js:33:1 onEvent +bound.js:34:38 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:35:26 FUNCTION_CALL handler -> known_edge bound.js:33:1 onEvent +bound.js:37:43 METHOD_CALL this.store.append -> known_edge bound.js:9:3 append +bound.js:39:24 FUNCTION_CALL_BIND handlers.created.bind -> known_edge bound.js:37:12 onCreated +bound.js:39:55 CONSTRUCTOR_CALL Other -> implicit_constructor - +bound.js:42:30 METHOD_CALL this.repo.append -> ambiguous_unknown - +bound.js:43:34 FUNCTION_CALL_CALL unbound.call -> known_edge bound.js:42:1 unbound +bound.js:43:55 CONSTRUCTOR_CALL Repo -> implicit_constructor - +bound.js:44:18 FUNCTION_CALL_BIND repo.find.bind -> known_edge bound.js:6:3 find +bound.js:45:35 FUNCTION_CALL_BIND fn.bind -> ambiguous_unknown - +bound.js:45:57 FUNCTION_CALL g -> ambiguous_unknown - +bound.js:47:17 METHOD_CALL own.promisify -> callback_registered bound.js:6:3 find +bound.js:47:17 METHOD_CALL own.promisify -> known_edge bound.js:46:15 promisify +bound.js:48:37 FUNCTION_CALL notUtil -> known_edge bound.js:46:37 diff --git a/graph/test/javascript/expected/73-bound-function-identity.oracle b/graph/test/javascript/expected/73-bound-function-identity.oracle new file mode 100644 index 000000000..01c9e34bd --- /dev/null +++ b/graph/test/javascript/expected/73-bound-function-identity.oracle @@ -0,0 +1,15 @@ +bound.js:22:33 CONSTRUCTOR_CALL Service EXACT bound.js:14:3 +bound.js:22:45 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:23:14 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:24:15 FUNCTION_CALL_BIND bind EXACT bound.js:5:3 +bound.js:26:27 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:29:37 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:34:17 FUNCTION_CALL_BIND bind EXACT bound.js:33:1 +bound.js:34:38 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:39:24 FUNCTION_CALL_BIND bind EXACT bound.js:37:12 +bound.js:39:55 CONSTRUCTOR_CALL Other SYNTHESIZED_OK +bound.js:43:34 FUNCTION_CALL_CALL call EXACT bound.js:42:1 +bound.js:43:55 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK +bound.js:44:18 FUNCTION_CALL_BIND bind EXACT bound.js:6:3 +bound.js:47:17 METHOD_CALL promisify EXACT bound.js:46:15 +# defects: 0 diff --git a/graph/test/python/cases/08-callables/src/main.py b/graph/test/python/cases/08-callables/src/main.py index 80ac9186e..baa116aaa 100644 --- a/graph/test/python/cases/08-callables/src/main.py +++ b/graph/test/python/cases/08-callables/src/main.py @@ -9,7 +9,8 @@ partial functools.partial exposes `.func`, so CPython can name the target lambda stored in a dict, reached by subscript: no name at the call site attribute a plain function assigned to an instance attribute, which does NOT - go through the descriptor protocol and so is not a bound method + go through the descriptor protocol and so is not a bound method; + a partial stored there calls its target the same way """ import functools @@ -31,10 +32,16 @@ class Holder: def __init__(self): # A function on an INSTANCE attribute. Not a method: no `self` is bound. self.op = add + self.bump = functools.partial(add, 1) + # control: a partial over a builtin stays the platform's + self.biggest = functools.partial(max, 0) def use(self): return self.op(1, 2) + def use_partial(self): + return self.bump(2) + self.biggest(3) + TABLE = { # A lambda reached by subscript. There is no name to resolve. @@ -52,6 +59,7 @@ def main(): print(TABLE["double"](21)) print(Holder().use()) + print(Holder().use_partial()) if __name__ == "__main__": diff --git a/graph/test/python/expected/08-callables.edges b/graph/test/python/expected/08-callables.edges index a89f21f40..5b0c619d2 100644 --- a/graph/test/python/expected/08-callables.edges +++ b/graph/test/python/expected/08-callables.edges @@ -1,7 +1,11 @@ +ambiguous_unknown SELF_CALL main.Holder.use_partial -> - +boundary_lib METHOD_CALL main.Holder.__init__ -> external:functools.partial boundary_lib METHOD_CALL main.main -> external:functools.partial boundary_lib SIMPLE_CALL main.main -> builtin:print known_edge CHAINED_CALL main.main -> main.Holder.use +known_edge CHAINED_CALL main.main -> main.Holder.use_partial known_edge SELF_CALL main.Holder.use -> main.add +known_edge SELF_CALL main.Holder.use_partial -> main.add known_edge SIMPLE_CALL main. -> main.main known_edge SIMPLE_CALL main.main -> main.Holder.__init__ known_edge SIMPLE_CALL main.main -> main.Multiplier.__call__ diff --git a/graph/test/python/expected/08-callables.tiers b/graph/test/python/expected/08-callables.tiers index 8c81e90ff..9d83aad23 100644 --- a/graph/test/python/expected/08-callables.tiers +++ b/graph/test/python/expected/08-callables.tiers @@ -1,26 +1,28 @@ -distinct call sites emitted: 13 +distinct call sites emitted: 20 --- by tier: edge ROWS, and the distinct SITES they cover --- - 5 rows 5 sites boundary_lib - 8 rows 8 sites known_edge + 1 rows 1 sites ambiguous_unknown + 8 rows 8 sites boundary_lib + 11 rows 11 sites known_edge --- edge rows by call kind --- - 1 CHAINED_CALL - 1 METHOD_CALL - 1 SELF_CALL - 9 SIMPLE_CALL + 2 CHAINED_CALL + 3 METHOD_CALL + 3 SELF_CALL + 11 SIMPLE_CALL 1 SUBSCRIPT_CALL --- unresolved reasons --- - (none — every site resolved) + 1 no_rule --- the engine's own conservation ledger --- - 13 _total_sites - 5 boundary_lib - 8 known_edge + 20 _total_sites + 1 ambiguous_unknown + 8 boundary_lib + 11 known_edge --- reconciling rows against the conserved site count --- - edge rows 13 + edge rows 20 minus extra rows from multi-target sites 0 - = tier/site pairs 13 - engine's conserved site total 13 + = tier/site pairs 20 + engine's conserved site total 20 diff --git a/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts new file mode 100644 index 000000000..e462aa74d --- /dev/null +++ b/graph/test/typescript/cases/84-declared-this-parameter/src/x.ts @@ -0,0 +1,17 @@ +class Repo { append(x: number): number { return x; } } +class Other { append(x: number): number { return x; } } +interface Ctx { repo: Repo } + +// A declared `this` parameter is what `this` is inside the function, whoever binds it. +function onEvent(this: Ctx, e: number) { return this.repo.append(e); } +function onShape(this: { other: Other }, e: number) { return this.other.append(e); } +const h = onEvent.bind({ repo: new Repo() }); +const s = onShape.bind({ other: new Other() }); + +// controls: an untyped `this` stays unknown; a class member's `this` is still its class +function onUntyped(this: any, e: number) { return this.repo.append(e); } +class Owner { + repo = new Repo(); + run(e: number) { return this.repo.append(e); } +} +export { h, s, onUntyped, Owner }; diff --git a/graph/test/typescript/expected/84-declared-this-parameter.edges b/graph/test/typescript/expected/84-declared-this-parameter.edges new file mode 100644 index 000000000..184ea7af5 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.edges @@ -0,0 +1,9 @@ +ambiguous_unknown METHOD_CALL x#() @L8 -> - +ambiguous_unknown METHOD_CALL x#() @L9 -> - +ambiguous_unknown METHOD_CALL x#onUntyped(number) @L12 -> - +known_edge CONSTRUCTOR_CALL x#() @L14 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L8 -> Repo#() +known_edge CONSTRUCTOR_CALL x#() @L9 -> Other#() +known_edge METHOD_CALL Owner#run(number) @L15 -> Repo#append(number) +known_edge METHOD_CALL x#onEvent(number) @L6 -> Repo#append(number) +known_edge METHOD_CALL x#onShape(number) @L7 -> Other#append(number) diff --git a/graph/test/typescript/expected/84-declared-this-parameter.entries b/graph/test/typescript/expected/84-declared-this-parameter.entries new file mode 100644 index 000000000..8e41e4b47 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.entries @@ -0,0 +1,3 @@ +── entry_point (2) ── + exported_from_entry_module x#onUntyped x.ts:12 + unimported_module x# x.ts:1 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields b/graph/test/typescript/expected/84-declared-this-parameter.fields new file mode 100644 index 000000000..de3e89d91 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields @@ -0,0 +1,4 @@ +ambiguous_unknown read x#onUntyped(number) -> - +known_edge read Owner#run(number) -> Owner#repo +known_edge read x#onEvent(number) -> Ctx#repo +known_edge read x#onShape(number) -> { other: Other }#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle new file mode 100644 index 000000000..0a3c5b6fb --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.fields-oracle @@ -0,0 +1,9 @@ +84-declared-this-parameter [fields] + precision 0.6667 (2 correct, 1 wrong) + recall 0.6667 (2 of 3 the compiler resolved) + sites 4 resolved 3 (75.0%) + tiers ambiguous_unknown=1 known_edge=3 + access read=4 + not scored: 1 rows whose target is not a client declaration + WRONG x#onShape(number) READ { other: Other }#other + MISSING x#onShape(number) READ x#other diff --git a/graph/test/typescript/expected/84-declared-this-parameter.oracle b/graph/test/typescript/expected/84-declared-this-parameter.oracle new file mode 100644 index 000000000..6c4bb5cbb --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.oracle @@ -0,0 +1 @@ +oracle=5 engine=5 agree=5 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/84-declared-this-parameter.type-use b/graph/test/typescript/expected/84-declared-this-parameter.type-use new file mode 100644 index 000000000..f183c671d --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.type-use @@ -0,0 +1,6 @@ +known_edge FIELD_TYPE 0 Ctx [FIELD] -> Repo +known_edge FIELD_TYPE 1 x [METHOD_PARAM] -> Other +known_edge METHOD_PARAM 0 x [METHOD_PARAM] -> Ctx +known_edge OBJECT_CREATION_TYPE 0 Owner [EXPRESSION] -> Repo +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Other +known_edge OBJECT_CREATION_TYPE 0 x [EXPRESSION] -> Repo diff --git a/graph/test/typescript/expected/84-declared-this-parameter.types-oracle b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle new file mode 100644 index 000000000..d0200fc91 --- /dev/null +++ b/graph/test/typescript/expected/84-declared-this-parameter.types-oracle @@ -0,0 +1,7 @@ +84-declared-this-parameter [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 6 resolved 6 (100.0%) + tiers known_edge=6 + contexts FIELD_TYPE=2 METHOD_PARAM=1 OBJECT_CREATION_TYPE=3 + 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 ef0341117..907c664c2 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -204,7 +204,10 @@ export function loadProgram(srcDir, libDir, toolName, programDir) { const mods = ts.canHaveModifiers(decl) ? (ts.getModifiers(decl) ?? []) : []; if (mods.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) name = 'static ' + name; - const ps = (decl.parameters ?? []).map((param) => { + // A declared `this` parameter types `this`, it is no argument: the engine's label + // leaves it out, so this one does too. + const ps = (decl.parameters ?? []).filter((param) => + !(ts.isIdentifier(param.name) && param.name.text === 'this')).map((param) => { let t = param.type ? param.type.getText(sf) : '?'; if (param.dotDotDotToken && !t.endsWith('[]')) t += '[]'; return simple(t); diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index 7e0a11fc4..c049ce6d2 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -169,6 +169,16 @@ expr_type(e, "client", t) :- expr_referenced("client", "THIS", _, e), expr_enclosing_type(e, t). expr_type(e, "client", t) :- expr_kind("client", "THIS_REFERENCE", _, e), expr_enclosing_type(e, t). +// A declared `this` parameter (`function onEvent(this: Ctx, e: Evt)`) is what `this` is in +// that function, whoever binds it (`onEvent.bind(ctx)`, `.call(ctx)`, a framework): the +// compiler types `this.repo` from it, and without it the site had no receiver type at all. +expr_type(e, prov, t) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_type(p, prov, t). +expr_shape(e, s) :- this_expr(e), expr_enclosing_method(e, m), method_declared_this(m, p), + param_shape_target(p, s). +this_expr(e) :- expr_referenced("client", "THIS", _, e). +this_expr(e) :- expr_kind("client", "THIS_REFERENCE", _, e). +method_declared_this(m, p) :- param_shape("client", "THIS", _, _, p), param_decl("client", _, _, _, m, p). // `this` INSIDE A STATIC METHOD IS THE CLASS, so its members are the STATICS. A helper // class that calls its own statics through `this` is ordinary TypeScript — diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 36d89cb37..1d7675e05 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -370,6 +370,8 @@ .decl method_signature_role(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_signature_role_of(c0:symbol,c1:symbol) .decl method_this_param(c0:symbol) +.decl this_expr(c0:symbol) +.decl method_declared_this(c0:symbol,c1:symbol) .decl module_ambient_specifier(c0:symbol,c1:symbol,c2:symbol) .decl module_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl module_default_export(c0:symbol,c1:symbol,c2:symbol)