Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
14 changes: 14 additions & 0 deletions graph/test/typescript/expected/03-structural-satisfaction.edges
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
ambiguous_unknown FUNCTION_CALL duck#useLibrary() @L51 -> -
known_edge CONSTRUCTOR_CALL bus#<module-init>() @L9 -> Bus#<new>()
known_edge CONSTRUCTOR_CALL declared#<module-init>() @L12 -> Declared#<new>()
known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#<new>()
known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#<new>()
known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#<module-init>() @L13 -> Hidden#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#<new>(Pub)
known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#<new>()
known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#<new>(Pub)
known_edge CONSTRUCTOR_CALL stranger#<module-init>() @L7 -> Stranger#<new>()
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()
Original file line number Diff line number Diff line change
@@ -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#<module> bus.ts:1
unimported_module declared#<module> declared.ts:1
unimported_module duck#<module> duck.ts:1
unimported_module duck-pub#<module> duck-pub.ts:1
unimported_module stranger#<module> stranger.ts:1
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
known_edge read Relay#run() -> Relay#p
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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#<module-init>() @L9 -> Bus#<new>()
known_edge CONSTRUCTOR_CALL declared#<module-init>() @L12 -> Declared#<new>()
known_edge CONSTRUCTOR_CALL duck#drive() @L36 -> FileReader#<new>()
known_edge CONSTRUCTOR_CALL duck#drive() @L37 -> NetReader#<new>()
known_edge CONSTRUCTOR_CALL duck#useLibrary() @L46 -> FileReader#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#<module-init>() @L13 -> Hidden#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#wire() @L16 -> Duck#<new>()
known_edge CONSTRUCTOR_CALL duck-pub#wire() @L17 -> Relay#<new>(Pub)
known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Real#<new>()
known_edge CONSTRUCTOR_CALL pub#start() @L26 -> Relay#<new>(Pub)
known_edge CONSTRUCTOR_CALL stranger#<module-init>() @L7 -> Stranger#<new>()
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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
135 changes: 127 additions & 8 deletions graph/typescript/engine/resolution/structural-satisfaction.dl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading