From 504e6205e434954e1dd8755747cbbe7546828ffb Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:51:06 -0700 Subject: [PATCH] typescript: --closed-world on narrows the dispatch fan to constructible types, and records it type_instantiated was computed and never read, so a TypeScript multi_inferred fan was every override the hierarchy admits (CHA), while Python's is narrowed to what the program builds. Narrowing is only sound when every construction is visible, so it is opt-in: - --closed-world on (env AXIOM_DISPATCH_CLOSED_WORLD) admits an implementor only if a constructible type conforms to it: a typed `new`, or the class used as a value anywhere, in its own module (TYPE) or imported into another (IMPORT_BINDING, the container `useClass: X` shape); - each edge the premise removes is a dispatch_assumes_closed_world row (assumption-dispatch-closed-world.csv), and run.dispatch_closed_world says which mode ran; - the default fan and every golden are unchanged. Case 83 pins the default fan; closed-world-dispatch-test.sh solves it with the flag on: Unused, Orphan and TypeOnly drop (3 assumption rows), Built, Registered, Base (inherited by a constructed Leaf) and Provided (imported, handed over as useClass) stay. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- bin/axiomcode | 4 +- graph/bundle/SCHEMA.md | 1 + graph/bundle/schema.ts | 1 + graph/csharp/souffle/decls_all.dl | 1 + graph/csharp/souffle/gen_decls_all.py | 3 + graph/java/souffle/decls_all.dl | 1 + graph/javascript/souffle/decls_all.dl | 1 + graph/pipeline/run-souffle.sh | 23 +++++- graph/python/souffle/decls_all.dl | 1 + .../83-closed-world-dispatch/src/handlers.ts | 55 ++++++++++++++ .../83-closed-world-dispatch/src/impl.ts | 12 +++ .../83-closed-world-dispatch/src/module.ts | 7 ++ .../expected/83-closed-world-dispatch.edges | 16 ++++ .../expected/83-closed-world-dispatch.entries | 2 + .../83-closed-world-dispatch.envelope | 8 ++ .../expected/83-closed-world-dispatch.fields | 0 .../83-closed-world-dispatch.fields-oracle | 7 ++ .../83-closed-world-dispatch.known-missing | 7 ++ .../expected/83-closed-world-dispatch.oracle | 9 +++ .../83-closed-world-dispatch.type-use | 18 +++++ .../83-closed-world-dispatch.types-oracle | 7 ++ graph/test/typescript/run-tests.sh | 8 ++ .../tools/closed-world-dispatch-test.sh | 75 +++++++++++++++++++ .../callee-resolution.dl | 55 ++++++++++++++ graph/typescript/souffle/decls_all.dl | 6 ++ graph/typescript/souffle/export_manifest.tsv | 1 + 26 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts create mode 100644 graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.edges create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.entries create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.envelope create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.fields create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.known-missing create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.oracle create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.type-use create mode 100644 graph/test/typescript/expected/83-closed-world-dispatch.types-oracle create mode 100644 graph/test/typescript/tools/closed-world-dispatch-test.sh diff --git a/bin/axiomcode b/bin/axiomcode index 3e1d32c87..01c25295b 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -25,6 +25,8 @@ # # Engine options: # --dispatch-cap --lib-depth --jdk-depth --taint +# --closed-world narrow the dispatch fan to constructed types (TypeScript), recording +# each dropped edge as an assumption row # # Commands: # parser [--version V] [--exclude-tests] @@ -195,7 +197,7 @@ case "$cmd" in --language) lang="$2"; shift 2;; --src) src="$2"; shift 2;; --out) out="$2"; shift 2;; --library) libs="$2"; shift 2;; --progress) progress="$2"; shift 2;; --version|--slug) version="$2"; shift 2;; --exclude-tests) popts+=(--exclude-tests); shift;; - --intermediate|--dispatch-cap|--lib-depth|--jdk-depth|--taint) rest+=("$1" "$2"); shift 2;; + --intermediate|--dispatch-cap|--lib-depth|--jdk-depth|--taint|--closed-world) rest+=("$1" "$2"); shift 2;; --debug) rest+=(--debug); shift;; --*) die "unknown option $1 (try --help)";; *) pos+=("$1"); shift;; esac; done diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 625aac8d3..ff013e58b 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -215,6 +215,7 @@ What produced this bundle: one key/value row per fact about the run (language, e | `client_ir` | all | Path of the client IR directory the engine read. | | `library_roots` | all | Comma-separated library IR roots staged as the type oracle; empty for a client-only run. | | `dispatch_cap` | all | Fan-width cap on virtual dispatch in effect; `off` when uncapped. | +| `dispatch_closed_world` | all | `on` when the dispatch fan was narrowed to types the analysed code constructs (a closed-world premise; each dropped edge is an assumption row); `off` otherwise. Read by TypeScript. | | `jdk_depth` | all | Platform-library hop cap (engine-ii). | | `lib_depth` | all | External-library hop cap; `uncapped` when unset. | | `engine_ii` | all | `on` when the library-frontier forward chain (engine-ii) was included; `off` for a client-only solve. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 87b31156a..23e6b0adb 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -384,6 +384,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'run', column: 'key', value: 'client_ir', languages: 'all', meaning: 'Path of the client IR directory the engine read.' }, { table: 'run', column: 'key', value: 'library_roots', languages: 'all', meaning: 'Comma-separated library IR roots staged as the type oracle; empty for a client-only run.' }, { table: 'run', column: 'key', value: 'dispatch_cap', languages: 'all', meaning: 'Fan-width cap on virtual dispatch in effect; `off` when uncapped.' }, + { table: 'run', column: 'key', value: 'dispatch_closed_world', languages: 'all', meaning: '`on` when the dispatch fan was narrowed to types the analysed code constructs (a closed-world premise; each dropped edge is an assumption row); `off` otherwise. Read by TypeScript.' }, { table: 'run', column: 'key', value: 'jdk_depth', languages: 'all', meaning: 'Platform-library hop cap (engine-ii).' }, { table: 'run', column: 'key', value: 'lib_depth', languages: 'all', meaning: 'External-library hop cap; `uncapped` when unset.' }, { table: 'run', column: 'key', value: 'engine_ii', languages: 'all', meaning: '`on` when the library-frontier forward chain (engine-ii) was included; `off` for a client-only solve.' }, diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 4372d0de6..25be747de 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -18,6 +18,7 @@ // ============================================================================ .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl runtime_observed_edge(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/csharp/souffle/gen_decls_all.py b/graph/csharp/souffle/gen_decls_all.py index 4ccb74525..7a8f6cc07 100644 --- a/graph/csharp/souffle/gen_decls_all.py +++ b/graph/csharp/souffle/gen_decls_all.py @@ -69,6 +69,9 @@ # --dispatch-cap K: refuse a virtual-dispatch fan wider than K. Recall-risky, so # opt-in; empty means uncapped, which is the answer that cannot be wrong. "dispatch_cap": ("symbol",), + # --closed-world on: narrow the dispatch fan to constructed types (#473). Staged + # for every language by the shared executor; no C# rule reads it yet. + "dispatch_closed_world": ("symbol",), # --lib-depth: how far a client->lib chain is expanded. Empty means uncapped. "lib_max_depth": ("number",), # The shared executor ALWAYS stages these two, for every language, and emits one diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 625f6581d..0298ce49a 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -463,6 +463,7 @@ .decl lib_dispatch_width_low(c0:symbol,c1:number) .decl lib_wide_dispatch(c0:symbol) .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl field_final(c0:symbol) .decl field_init_new_type(c0:symbol,c1:symbol) .decl field_multi_new(c0:symbol) diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index a126c63c0..cae221d9e 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -380,6 +380,7 @@ .decl literal_contains_method_nested(c0:symbol, c1:symbol) // ── executor knobs (written by run-souffle.sh for every language; unread here) ── .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl taint_gating(c0:symbol) diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 4d47f08cc..0ec319717 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -45,7 +45,10 @@ DISPATCH_CAP="${DISPATCH_CAP:-20}" # fan-width cap on virtual dispatch. DEFAUL LANG_ARG="" # which rule set under graph// to run. Default java. TAINT="" # --taint on → gate lib→lib GROW on client-seeded data flow (dataflow/taint.dl). Also # settable via env AXIOM_TAINT_GATING=on. Empty = ungated (default behavior). -MODE="run" # run | print-engine-id | emit-program — the last two need no IR and no souffle +CLOSED_WORLD="" # --closed-world on → narrow the dispatch fan to types the program constructs (RTA), + # and record every edge that drops as an assumption row. Env AXIOM_DISPATCH_CLOSED_WORLD=on. + # Empty = the fan is every declared override (default). See #473. +MODE="run" # run | print-engine-id | emit-program — the last two need no IR and no souffle EMIT="" while [ $# -gt 0 ]; do case "$1" in --client-ir) CLIENT="$2"; shift 2;; --library) LIB="$2"; shift 2;; @@ -54,6 +57,7 @@ while [ $# -gt 0 ]; do case "$1" in --dispatch-cap) DISPATCH_CAP="$2"; shift 2;; --lib-depth) LIB_DEPTH="$2"; shift 2;; --taint) TAINT="$2"; shift 2;; + --closed-world) CLOSED_WORLD="$2"; shift 2;; --language) LANG_ARG="$2"; shift 2;; --print-engine-id) MODE="print-engine-id"; shift;; --emit-program) MODE="emit-program"; EMIT="$2"; shift 2;; @@ -195,7 +199,7 @@ write_program(){ map_rels_su "$TPL/client-ir.map" || return 1 map_rels_su "$TPL/lib.map" sig || return 1 for r in $LIB_BODY; do _SU+=("$r"); done - _SU+=(jdk_max_depth lib_max_depth taint_gating dispatch_cap) + _SU+=(jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world) sort_unique_su PROGRAM_INPUTS=(${_SU[@]+"${_SU[@]}"}) # rfc4180=true: the IR is CSV, not TSV. The parser quotes any field containing a @@ -247,7 +251,7 @@ write_program(){ # the expected one, each exactly once, with nothing extra. Exit status only: no pipe to lose. verify_program(){ awk -v cmap="$TPL/client-ir.map" -v lmap="$TPL/lib.map" -v man="$DL/export_manifest.tsv" \ - -v libsig=" $LIB_SIG " -v extra="$LIB_BODY jdk_max_depth lib_max_depth taint_gating dispatch_cap" ' + -v libsig=" $LIB_SIG " -v extra="$LIB_BODY jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world" ' function want(line) { if (!(line in need)) { need[line] = 1; n++ } } function inp(r) { want(".input " r "(IO=file, filename=\"" r ".facts\", delimiter=\"\\t\", rfc4180=true)") } function maprels(path, sigonly, l, rc, r, k) { @@ -504,6 +508,17 @@ echo "▶ taint gating = $( [ -s "$FACTS/taint_gating.facts" ] && echo 'on (opt- CAP_EFF="${AXIOM_DISPATCH_CAP:-$DISPATCH_CAP}" case "$CAP_EFF" in off|none|no|0|"") CAP_EFF="";; esac [ -n "$CAP_EFF" ] && printf '%s\n' "$CAP_EFF" > "$FACTS/dispatch_cap.facts" + +# dispatch_closed_world — OPT-IN RTA narrowing of the dispatch fan (#473). The file always exists +# so its .input directive is generated; EMPTY = the fan is every declared override (the default, +# and what every golden pins). "on" admits only overrides a constructed or escaping type can +# reach, and exports each dropped edge as an assumption row: the narrowing is sound only when +# every construction is in the analysed code, so the bundle says it was made. Only front ends +# that read the relation change; the others ignore it. +: > "$FACTS/dispatch_closed_world.facts" +CW_EFF="${AXIOM_DISPATCH_CLOSED_WORLD:-$CLOSED_WORLD}" +case "$CW_EFF" in on|yes|1|true) CW_EFF="on"; printf 'on\n' > "$FACTS/dispatch_closed_world.facts";; *) CW_EFF="off";; esac +echo "▶ dispatch closed world = $CW_EFF" echo "▶ dispatch cap = $( [ -s "$FACTS/dispatch_cap.facts" ] && echo "$(cat "$FACTS/dispatch_cap.facts") (default; --dispatch-cap off for unbounded reachability)" || echo 'OFF — uncapped/sound (sink & taint traversal)' )" # engine-ii (lib-frontier forward-chain) is GATED — default OFF so the build is CLIENT-ONLY @@ -803,7 +818,7 @@ BUNDLE_FLAGS=(); [ "$DEBUG_BUNDLE" = "1" ] && BUNDLE_FLAGS+=(--debug) "${BUNDLE[@]}" --language "$LANG_ARG" --src "$SRC" --client-ir "$CLIENT" --raw "$RAW" --out "$OUT" \ --library "$LIB" --lib-facts "$LIBDIR" "${BUNDLE_FLAGS[@]}" \ --meta "engine_commit=$ENGINE_COMMIT" \ - --meta "dispatch_cap=${CAP_EFF:-off}" --meta "jdk_depth=$JDK_DEPTH" --meta "lib_depth=${LIB_DEPTH:-uncapped}" \ + --meta "dispatch_cap=${CAP_EFF:-off}" --meta "dispatch_closed_world=$CW_EFF" --meta "jdk_depth=$JDK_DEPTH" --meta "lib_depth=${LIB_DEPTH:-uncapped}" \ --meta "engine_ii=$ENGINE_II_MODE" --meta "solve_iterations=$iter" --meta "solve_seconds=$((SOLVE_EPOCH-START_EPOCH))" \ ${EXTRA_META[@]+"${EXTRA_META[@]}"} diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 64b71d8fe..09a7d62c2 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -1,4 +1,5 @@ .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl jdk_max_depth(c0:number) .decl lib_max_depth(c0:number) .decl taint_gating(c0:symbol) diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts new file mode 100644 index 000000000..864290cb5 --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/handlers.ts @@ -0,0 +1,55 @@ +// A call through an interface-typed receiver fans to every declared implementor by +// default. `--closed-world on` narrows that fan to what the program can build (#473). +// Each class below is one shape of "can the program build it": + +export interface Handler { + handle(x: string): string; +} + +// constructed with `new`: stays in the narrowed fan +export class Built implements Handler { + handle(x: string): string { return "b" + x; } +} + +// never written in a `new`, but handed to a registry as a class VALUE that constructs it: +// stays, because a class that escapes as a value can be built where the parser cannot see +export class Registered implements Handler { + handle(x: string): string { return "r" + x; } +} + +// named only as a TYPE: nothing can build it, so the narrowed fan drops it +export class Unused implements Handler { + handle(x: string): string { return "u" + x; } +} + +// never constructed itself, but a constructed subclass inherits its body: stays +export abstract class Base implements Handler { + handle(x: string): string { return "base" + x; } +} +export class Leaf extends Base {} + +// a subclass nobody constructs, overriding: dropped +export class Orphan extends Base { + handle(x: string): string { return "o" + x; } +} + +type HandlerClass = new () => Handler; +const registry: HandlerClass[] = []; +export function register(c: HandlerClass): void { registry.push(c); } +register(Registered); + +export function dispatch(h: Handler, x: string): string { + return h.handle(x); +} + +export function main(): void { + const built = new Built(); + const leaf = new Leaf(); + let later: Unused | undefined; + let other: Orphan | undefined; + dispatch(built, "1"); + dispatch(leaf, "2"); + for (const C of registry) dispatch(new C(), "3"); + void later; + void other; +} diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts new file mode 100644 index 000000000..eaedd82a7 --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/impl.ts @@ -0,0 +1,12 @@ +import { Handler } from "./handlers"; + +// declared in one module, handed to a container as a class VALUE from another +// (`useClass: Provided` in module.ts): stays in the narrowed fan +export class Provided implements Handler { + handle(x: string): string { return "p" + x; } +} + +// imported by module.ts too, but only ever named there as a TYPE: dropped +export class TypeOnly implements Handler { + handle(x: string): string { return "t" + x; } +} diff --git a/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts b/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts new file mode 100644 index 000000000..fda3351a2 --- /dev/null +++ b/graph/test/typescript/cases/83-closed-world-dispatch/src/module.ts @@ -0,0 +1,7 @@ +import { Provided, TypeOnly } from "./impl"; + +// The container shape: the class crosses a module boundary as an imported value and is +// constructed by code the analysis does not see. +export const providers = [{ provide: "handler", useClass: Provided }]; + +export let pending: TypeOnly | undefined; diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.edges b/graph/test/typescript/expected/83-closed-world-dispatch.edges new file mode 100644 index 000000000..3e35361f4 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.edges @@ -0,0 +1,16 @@ +ambiguous_unknown CONSTRUCTOR_CALL handlers#main() @L52 -> - +ambiguous_unknown METHOD_CALL handlers#register(HandlerClass) @L38 -> - +known_edge CONSTRUCTOR_CALL handlers#main() @L46 -> Built#() +known_edge CONSTRUCTOR_CALL handlers#main() @L47 -> Base#() +known_edge FUNCTION_CALL handlers#() @L39 -> handlers#register(HandlerClass) +known_edge FUNCTION_CALL handlers#main() @L50 -> handlers#dispatch(Handler,string) +known_edge FUNCTION_CALL handlers#main() @L51 -> handlers#dispatch(Handler,string) +known_edge FUNCTION_CALL handlers#main() @L52 -> handlers#dispatch(Handler,string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Base#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Built#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Handler#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Orphan#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Provided#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Registered#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> TypeOnly#handle(string) +multi_inferred METHOD_CALL handlers#dispatch(Handler,string) @L42 -> Unused#handle(string) diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.entries b/graph/test/typescript/expected/83-closed-world-dispatch.entries new file mode 100644 index 000000000..37921bcc4 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.entries @@ -0,0 +1,2 @@ +── entry_point (1) ── + unimported_module module# module.ts:1 diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.envelope b/graph/test/typescript/expected/83-closed-world-dispatch.envelope new file mode 100644 index 000000000..d8c90f4ff --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.envelope @@ -0,0 +1,8 @@ +nominal handlers#Base.handle -> handlers#Orphan.handle +nominal handlers#Handler.handle -> handlers#Base.handle +nominal handlers#Handler.handle -> handlers#Built.handle +nominal handlers#Handler.handle -> handlers#Orphan.handle +nominal handlers#Handler.handle -> handlers#Registered.handle +nominal handlers#Handler.handle -> handlers#Unused.handle +nominal handlers#Handler.handle -> impl#Provided.handle +nominal handlers#Handler.handle -> impl#TypeOnly.handle diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields b/graph/test/typescript/expected/83-closed-world-dispatch.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle new file mode 100644 index 000000000..3355e7406 --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle @@ -0,0 +1,7 @@ +83-closed-world-dispatch [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.known-missing b/graph/test/typescript/expected/83-closed-world-dispatch.known-missing new file mode 100644 index 000000000..15bb2695f --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.known-missing @@ -0,0 +1,7 @@ +# Accepted gaps for 83-closed-world-dispatch — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# `new C()` where C is typed by a construct-signature alias: the compiler names the +# alias's signature, the engine leaves the site ambiguous_unknown. Unrelated to the +# closed-world fan this case is about. +handlers#main() -> handlers#() diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.oracle b/graph/test/typescript/expected/83-closed-world-dispatch.oracle new file mode 100644 index 000000000..7f00d858a --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.oracle @@ -0,0 +1,9 @@ +oracle=6 engine=12 agree=5 missing=1 (known 1, NEW 0) extra=7 + known handlers#main() -> handlers#() + extra handlers#dispatch(Handler,string) -> Base#handle(string) + extra handlers#dispatch(Handler,string) -> Built#handle(string) + extra handlers#dispatch(Handler,string) -> Orphan#handle(string) + extra handlers#dispatch(Handler,string) -> Provided#handle(string) + extra handlers#dispatch(Handler,string) -> Registered#handle(string) + extra handlers#dispatch(Handler,string) -> TypeOnly#handle(string) + extra handlers#dispatch(Handler,string) -> Unused#handle(string) diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.type-use b/graph/test/typescript/expected/83-closed-world-dispatch.type-use new file mode 100644 index 000000000..b398f762a --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.type-use @@ -0,0 +1,18 @@ +ambiguous_unknown OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> - +known_edge IMPLEMENTS_INTERFACE 0 Base [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Built [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Provided [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Registered [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 TypeOnly [HERITAGE] -> Handler +known_edge IMPLEMENTS_INTERFACE 0 Unused [HERITAGE] -> Handler +known_edge METHOD_PARAM 0 handlers [METHOD_PARAM] -> Handler +known_edge METHOD_PARAM 0 handlers [METHOD_PARAM] -> HandlerClass +known_edge METHOD_RETURN 1 HandlerClass [TYPE] -> Handler +known_edge OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> Built +known_edge OBJECT_CREATION_TYPE 0 handlers [EXPRESSION] -> Leaf +known_edge SUPER_TYPE 0 Leaf [HERITAGE] -> Base +known_edge SUPER_TYPE 0 Orphan [HERITAGE] -> Base +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> HandlerClass +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> Orphan +known_edge TYPE_ELEMENT 1 handlers [VARIABLE] -> Unused +known_edge TYPE_ELEMENT 1 module [VARIABLE] -> TypeOnly diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle new file mode 100644 index 000000000..9f55c300b --- /dev/null +++ b/graph/test/typescript/expected/83-closed-world-dispatch.types-oracle @@ -0,0 +1,7 @@ +83-closed-world-dispatch [types] + precision 1.0000 (16 correct, 0 wrong) + recall 1.0000 (16 of 16 the compiler resolved) + sites 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + contexts IMPLEMENTS_INTERFACE=6 METHOD_PARAM=2 METHOD_RETURN=1 OBJECT_CREATION_TYPE=3 SUPER_TYPE=2 TYPE_ELEMENT=4 + not scored: 1 rows whose target is not a client declaration diff --git a/graph/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh index ae67e28e8..145cb86bb 100755 --- a/graph/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -247,6 +247,14 @@ if ! bash "$HERE/tools/envelope-merge-test.sh"; then exit 1 fi PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +# ── --closed-world on narrows the dispatch fan and records it (#473) ───────── +# The goldens pin the default (CHA) fan; this solves case 82 with the flag on and checks +# that exactly the never-constructed implementors drop, each with an assumption row, and +# that a class built through `new`, through a registry, or through a subclass stays. +if ! bash "$HERE/tools/closed-world-dispatch-test.sh"; then + echo "aborting: --closed-world does not narrow the fan the way it claims" + exit 1 +fi WORK="$HERE/.work" # shellcheck source=../tools/case-pool.sh . "$ROOT/graph/test/tools/case-pool.sh" diff --git a/graph/test/typescript/tools/closed-world-dispatch-test.sh b/graph/test/typescript/tools/closed-world-dispatch-test.sh new file mode 100644 index 000000000..c7070d159 --- /dev/null +++ b/graph/test/typescript/tools/closed-world-dispatch-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# `--closed-world on` NARROWS THE DISPATCH FAN, AND SAYS SO (#473). +# +# By default an interface-typed call fans to every declared implementor (CHA). With the +# flag the fan is narrowed to the implementors a value the program can build could run +# (RTA), and every edge the premise removed is exported as a +# dispatch_assumes_closed_world row. The default is pinned by the ordinary golden of case +# 83-closed-world-dispatch; this solves the same case with the flag on and checks: +# +# dropped Unused (named only as a type), Orphan (a subclass nobody constructs), +# TypeOnly (imported into another module, but named there only as a type) +# KEPT Built (a `new`), Registered (handed to a registry as a class VALUE — the +# DI/factory shape), Base (never constructed, but its body runs on a Leaf), +# Provided (imported into another module and handed over as `useClass:` — +# the container shape, where the value is an import binding, not the class) +# and the assumption file names exactly the three dropped targets, while the flag-off +# run writes none. +# The four kept targets are the controls: each is a way a class is built without the +# engine seeing a typed `new` of it, and losing any one would be a wrong narrowing. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +CASE="$HERE/../cases/83-closed-world-dispatch" + +fail=0; checks=0 +ok(){ checks=$((checks+1)); return 0; } +bad(){ checks=$((checks+1)); printf ' FAIL %s\n' "$1"; fail=1; } + +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +mkdir -p "$W/ir" "$W/empty" +if ! node "$PARSER" "$CASE/src" cw false "$W/ir" >"$W/parse.log" 2>&1; then + echo " FAIL parse — see the log below"; sed 's/^/ /' "$W/parse.log" | tail -20 + echo "closed-world-dispatch: FAILED"; exit 1 +fi +for mode in off on; do + if ! bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language typescript --closed-world "$mode" \ + --client-ir "$W/ir" --library "$W/empty" --intermediate "$W/$mode/int" --output "$W/$mode/out" \ + >"$W/$mode.log" 2>&1; then + echo " FAIL solve ($mode)"; tail -20 "$W/$mode.log" | sed 's/^/ /' + echo "closed-world-dispatch: FAILED"; exit 1 + fi + python3 "$HERE/normalize_edges.py" "$W/ir" "$W/$mode/out/raw" > "$W/$mode.edges" +done + +site='handlers#dispatch(Handler,string) @L42 -> ' +has(){ grep -qF "multi_inferred METHOD_CALL $site$2" "$W/$1.edges"; } + +kept='Built#handle(string) Registered#handle(string) Base#handle(string) Provided#handle(string)' +dropped='Unused#handle(string) Orphan#handle(string) TypeOnly#handle(string)' +for t in $kept $dropped; do + has off "$t" && ok || bad "flag off: the CHA fan lost $t — the default must not move" +done +for t in $kept; do + has on "$t" && ok || bad "flag on: $t was narrowed away, but the program can build a value that runs it" +done +for t in $dropped; do + has on "$t" && bad "flag on: $t is still in the fan, though nothing constructs it" || ok +done + +gone="$(comm -23 <(sort "$W/off.edges") <(sort "$W/on.edges") | wc -l | tr -d ' ')" +added="$(comm -13 <(sort "$W/off.edges") <(sort "$W/on.edges") | wc -l | tr -d ' ')" +[ "$gone" = 3 ] && [ "$added" = 0 ] && ok || bad "flag on moved $gone edge(s) out and $added in; expected exactly the 3 dropped fan edges" + +aoff="$W/off/out/raw/assumption-dispatch-closed-world.csv" +aon="$W/on/out/raw/assumption-dispatch-closed-world.csv" +[ -f "$aoff" ] && [ ! -s "$aoff" ] && ok || bad "flag off wrote assumption rows (or no file): the default makes no closed-world step" +n="$(wc -l < "$aon" 2>/dev/null | tr -d ' ')" +[ "${n:-0}" = 3 ] && ok || bad "flag on wrote ${n:-no} assumption row(s); expected one per dropped edge (3)" +grep -q "dispatch closed world = on" "$W/on.log" && ok || bad "the solve log does not report the closed-world mode" + +if [ "$fail" != 0 ]; then echo "closed-world-dispatch: FAILED ($checks checks)"; exit 1; fi +echo "closed-world-dispatch: ok ($checks checks)" diff --git a/graph/typescript/engine/expression-resolution/callee-resolution.dl b/graph/typescript/engine/expression-resolution/callee-resolution.dl index db0a0bd17..6fe5e6801 100644 --- a/graph/typescript/engine/expression-resolution/callee-resolution.dl +++ b/graph/typescript/engine/expression-resolution/callee-resolution.dl @@ -799,6 +799,7 @@ expr_call_dispatch(ce, m) :- call_site("client", _, cn, rk, recv, ce, _), !call_super_receiver(ce), !dispatch_too_wide(rt, cn), implementors(rt, sub), + dispatch_admits(sub), declared_method_direct(sub, cn, "false", m). // ── THE STRUCTURAL FAN ────────────────────────────────────────────────────── @@ -843,6 +844,7 @@ call_super_receiver(ce) :- call_site("client", _, _, "SUPER", _, ce, _). dispatch_width(rt, cn, n) :- implementor_member(rt, cn, _), n = count : { implementor_member(rt, cn, _) }. implementor_member(rt, cn, m) :- implementors(rt, sub), + dispatch_admits(sub), declared_method_direct(sub, cn, "false", m). implementor_member(rt, cn, m) :- structural_implementor(rt, sub), declared_method_direct(sub, cn, "false", m). @@ -851,6 +853,59 @@ dispatch_too_wide(rt, cn) :- dispatch_width(rt, cn, n), k = to_number(c), n > k. +// ── the closed-world narrowing (#473) — OPT-IN ────────────────────────────── +// By default the nominal fan is every declared override the hierarchy admits (CHA), +// bounded only by the cap. With `--closed-world on` it is narrowed to the overrides a +// value the program can build could run (RTA), which is how Python's self-dispatch is +// already bounded. The two are different claims under one tier, so the default is +// unchanged and the narrowed run says it was narrowed: `run.dispatch_closed_world`, and +// one dispatch_assumes_closed_world row per edge the premise removed. +// +// NARROWING IS SOUND ONLY IF EVERY CONSTRUCTION IS VISIBLE, and TypeScript has two +// common ways to build a class without a `new` the parser can type: a framework or +// container instantiates a class it was HANDED (`providers: [OrderService]`, +// `register(Handler)`), and a factory calls `new C()` on a class it received as a +// value. Both hand the class around as a VALUE, so a class referenced as a value +// anywhere counts as constructible, alongside every typed `new`. What is left out is a +// class that is only ever named as a TYPE, or not named at all — the case RTA exists for. +// +// A SUBTYPE IS LIVE IF A CONSTRUCTIBLE TYPE CONFORMS TO IT, not only if it is itself +// constructed: `class Mid { run() {} }` extended by a constructed `Leaf` that does not +// redeclare `run` executes Mid.run, and dropping Mid would lose an edge a run takes. The +// conformance closure (not the member-inheritance one) is used so an interface that +// only a constructed class implements stays in the fan exactly as it is today. +closed_world_dispatch() :- dispatch_closed_world("on"). +type_constructible(t) :- type_instantiated(t, _). +type_constructible(t) :- expr_referenced("client", "TYPE", t, _). +// ...and from ANOTHER module the same value is an IMPORT_BINDING, not a TYPE: a Nest +// module's `useClass: FileRelationalRepository` names the class it imported. Followed +// through import_binds directly, not expr_static_type, which sits inside the call +// resolution this narrowing feeds (dispatch_too_wide negates it: a stratification cycle). +type_constructible(t) :- expr_referenced("client", "IMPORT_BINDING", ih, _), + import_binds(ih, _, "TYPE", t). +type_live(t) :- type_constructible(t). +type_live(anc) :- type_constructible(t), type_subtype_star(t, anc). +type_live(sib) :- type_live(t), scope_sibling(t, sib). +dispatch_admits(sub) :- implementors(_, sub), !closed_world_dispatch(). +dispatch_admits(sub) :- implementors(_, sub), closed_world_dispatch(), type_live(sub). + +// ── dispatch_assumes_closed_world(CallExprHash, DroppedMethodHash) ────────── +// THE ASSUMPTION IS A ROW, NOT A FOOTNOTE — the counterpart of Python's +// self_dispatch_assumes_closed_world. Each row is an edge the CHA fan had and the +// narrowed fan does not, so a consumer can count what the premise cost and restore it +// for a library whose subclasses live in someone else's code. Empty unless the flag is on. +dispatch_assumes_closed_world(ce, m) :- closed_world_dispatch(), + call_site("client", _, cn, rk, recv, ce, _), + receiver_kind_is_value(rk), + cn != "", + expr_type(recv, _, rt), + !call_exact_receiver(ce), + !call_super_receiver(ce), + !dispatch_too_wide(rt, cn), + implementors(rt, sub), + !type_live(sub), + declared_method_direct(sub, cn, "false", m). + // ── the merged candidate set ──────────────────────────────────────────────── expr_call_candidate(ce, m) :- expr_call_dispatch(ce, m). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 22d38679a..36d89cb37 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -97,6 +97,7 @@ .decl declared_method_direct(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl demanded_qualified_name(c0:symbol) .decl dispatch_cap(c0:symbol) +.decl dispatch_closed_world(c0:symbol) .decl dispatch_too_wide(c0:symbol,c1:symbol) .decl dispatch_width(c0:symbol,c1:symbol,c2:number) .decl dynamic_import_site(c0:symbol) @@ -703,3 +704,8 @@ .decl remote_undetermined(c0:symbol,c1:symbol,c2:symbol) .decl remote_unsent(c0:symbol,c1:symbol,c2:symbol) .decl remote_unserved(c0:symbol,c1:symbol,c2:symbol) +.decl closed_world_dispatch() +.decl dispatch_admits(c0:symbol) +.decl dispatch_assumes_closed_world(c0:symbol,c1:symbol) +.decl type_constructible(c0:symbol) +.decl type_live(c0:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index 23b265dfb..f319cddf5 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -54,3 +54,4 @@ remote_edge remote-edge.csv remote_unserved remote-unserved.csv remote_unsent remote-unsent.csv remote_undetermined remote-undetermined.csv +dispatch_assumes_closed_world assumption-dispatch-closed-world.csv