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/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/duck-pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts new file mode 100644 index 000000000..6af8cd5e5 --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/duck-pub.ts @@ -0,0 +1,18 @@ +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 {} +} + +// 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/cases/03-structural-satisfaction/src/pub.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts new file mode 100644 index 000000000..cc19ffdeb --- /dev/null +++ b/graph/test/typescript/cases/03-structural-satisfaction/src/pub.ts @@ -0,0 +1,27 @@ +// 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. +// 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; +} + +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..46193873d 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.edges @@ -1,9 +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() @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() @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 224a7991e..e5fbd8c49 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.envelope @@ -1,2 +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.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..ac4f8304f 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.edges +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges @@ -1,9 +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() @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() @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 595d139aa..620432e53 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope +++ b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope @@ -1,3 +1,6 @@ +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 3716c5f08..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,7 @@ -oracle=7 engine=10 agree=7 missing=0 (known 0, NEW 0) extra=3 +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() 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..7546b16d7 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.oracle +++ b/graph/test/typescript/expected/03-structural-satisfaction.oracle @@ -1,4 +1,7 @@ -oracle=5 engine=8 agree=5 missing=0 (known 0, NEW 0) extra=3 +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() 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..04be63a89 100644 --- a/graph/test/typescript/expected/03-structural-satisfaction.type-use +++ b/graph/test/typescript/expected/03-structural-satisfaction.type-use @@ -1,4 +1,17 @@ +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 +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 +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..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 (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 - 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 2753406b4..c2ab16dfe 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,68 @@ 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). +// 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. `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 +180,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 +264,66 @@ 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). +// 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 36d89cb37..050acd2a8 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -709,3 +709,18 @@ .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_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) +.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) 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()); +}