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