diff --git a/bin/axiomcode b/bin/axiomcode index f5a73ca2..6b902cf0 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -37,6 +37,10 @@ # Parse only; writes //. # engine --language L --client-ir / --out [options] # Solve previously parsed IR. +# prepare [--language L[,…]] [--background] +# Make each language's engine binary ready (packaged, cached, or compiled +# now), so the first index does not pay the compile. `npm run build` runs it +# in the background; AXIOMCODE_NO_PREPARE=1 or CI skips that. # test [java|typescript|python|javascript|csharp|parser|all] [suite options] # Run the test suites. # mcp Serve the graph to an agent as MCP tools over stdio. Any MCP client @@ -144,7 +148,7 @@ esac # for `axiomcode ./src out/` and wrong for everything else: a misspelled verb used to be parsed as a # source tree and fail with advice about building the parser. case "$cmd" in - parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; + parser|engine|prepare|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; *) [ -d "$cmd" ] || { echo "axiomcode: '$cmd' is neither a verb nor a directory." >&2 echo " ask: $(public_verbs | tr '\n' ' ')" >&2 echo " \`axiomcode help\` for what each one does." >&2; exit 2; } @@ -189,6 +193,37 @@ case "$cmd" in has --library || args+=(--library "") bash "$ROOT/graph/pipeline/run-souffle.sh" "${args[@]}" ;; + prepare) + # EVERY LANGUAGE'S ENGINE BINARY, BEFORE THE FIRST INDEX ASKS FOR IT. On a cache miss the engine is compiled to C++ + # and built by the C++ compiler: minutes, whatever the size of the repository, while the index it serves takes + # seconds. Left to the first index, that cost landed on whoever indexed first after an install or a rule change, + # and on a query that had to build a graph. `npm run build` runs this in the background (postbuild), so an install + # or a refresh starts the compiles; an index that starts meanwhile waits on the same lock instead of compiling twice. + langs=""; bg=false + while [ $# -gt 0 ]; do case "$1" in + --language) langs="$2"; shift 2;; --background) bg=true; shift;; + *) die "usage: axiomcode prepare [--language L[,…]] [--background]";; esac; done + if [ -z "$langs" ]; then + for d in "$ROOT"/graph/*/engine; do [ -d "$d" ] && { d="${d%/engine}"; langs="$langs,${d##*/}"; }; done + langs="${langs#,}" + fi + IFS=',' read -ra want <<< "$langs" + for l in ${want[@]+"${want[@]}"}; do [ -d "$ROOT/graph/$l/engine" ] || die "prepare: no engine for --language=$l"; done + if $bg; then + # a CI job compiles the engines it tests itself, with its own cache and -march; a background compile there only + # competes with the job for the machine + if [ -n "${AXIOMCODE_NO_PREPARE:-}" ] || [ -n "${CI:-}" ]; then echo "▶ engines not prepared (${AXIOMCODE_NO_PREPARE:+AXIOMCODE_NO_PREPARE}${CI:+CI} is set)"; exit 0; fi + log="${TMPDIR:-/tmp}/axiomcode-prepare-$(id -u 2>/dev/null || echo 0).log" + nohup bash "$0" prepare --language "$langs" > "$log" 2>&1 < /dev/null & + echo "▶ preparing the engines for ${langs//,/, } in the background (log: $log)" + exit 0 + fi + # one compile per language, side by side: each is one single-threaded compiler process of a few hundred MB + ncpu="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)"; jobs="${AXIOM_PREPARE_JOBS:-$(( ncpu / 2 ))}" + [ "$jobs" -ge 1 ] 2>/dev/null || jobs=1; [ "$jobs" -le ${#want[@]} ] || jobs=${#want[@]} + printf '%s\n' "${want[@]}" | xargs -P "$jobs" -I{} bash -c \ + 'set -o pipefail; bash "$1" --language "$2" --prepare 2>&1 | sed "s/^/[$2] /"' _ "$ROOT/graph/pipeline/run-souffle.sh" {} + ;; all) need_parser lang=""; src=""; out=""; version=""; libs=""; progress=""; popts=(); rest=(); pos=() diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 65f9a6c0..2c17b391 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -557,6 +557,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | | `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | +| `event_dispatch` | typescript | A publish on a project class's keyed registry reaching each function filed under the same key: `bus.publish('x', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe('x', f)` stored there, or an entry `{ ['x']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl). | | `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity's type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | @@ -577,7 +578,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row. - **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 00335314..2e9af322 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -514,6 +514,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: T, meaning: 'A publish on a project class\'s keyed registry reaching each function filed under the same key: `bus.publish(\'x\', p)` where `publish` (or a method it passes the key to) calls what `this.handlers.get(name)` holds, to the function a `bus.subscribe(\'x\', f)` stored there, or an entry `{ [\'x\']: f }` of a table a subscribe-all method files by key. A literal, a const or a const-object member key; a key only known at run time matches every key, and a constant the dispatch also looks up (a wildcard) matches every publish. The site is the publish call (call-edge-generation/keyed_registry.dl).' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). Also a JPA entity write (`save`, `persist`, `merge`, `delete` on a repository or EntityManager) reaching the `@PrePersist`/`@PreUpdate`/`@PreRemove`-style callbacks of the written entity\'s type and of the listeners `@EntityListeners` names on it or a superclass (kind entity_callback; call-edge-generation/entity_lifecycle.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, @@ -755,7 +756,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript and TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 0d58ba94..c26c8774 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -5,6 +5,7 @@ # Usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L] [--debug] # run-souffle.sh --language L --print-engine-id the canonical id of L's compiled engine # run-souffle.sh --language L --emit-program FILE the Soufflé program CI compiles for L +# run-souffle.sh --language L --prepare make L's engine binary ready (packaged, cached or compiled) # # NO SOUFFLÉ NEEDED TO RUN. The rules compile to one self-contained executable that is # project-independent; CI builds it for every platform and publishes it on npm as @@ -48,7 +49,7 @@ TAINT="" # --taint on → gate lib→lib GROW on client-seeded data flow (d 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 +MODE="run" # run | print-engine-id | emit-program | prepare — none but run needs IR; the id and the program need no souffle EMIT="" while [ $# -gt 0 ]; do case "$1" in --client-ir) CLIENT="$2"; shift 2;; --library) LIB="$2"; shift 2;; @@ -61,6 +62,7 @@ while [ $# -gt 0 ]; do case "$1" in --language) LANG_ARG="$2"; shift 2;; --print-engine-id) MODE="print-engine-id"; shift;; --emit-program) MODE="emit-program"; EMIT="$2"; shift 2;; + --prepare) MODE="prepare"; shift;; # graph.sqlite is the deliverable; csv/*.csv is a debugging view of the same core # tables. --debug asks for both. (An older Node with no node:sqlite writes the CSVs # regardless, because otherwise the run would produce no consumer-facing output.) @@ -334,21 +336,7 @@ engine_id_of(){ fi ENGINE_ID="$h" } -case "$MODE" in - print-engine-id) - _pd="$(mktemp -d "${TMPDIR:-/tmp}/axiom-program.XXXXXX")" && [ -d "$_pd" ] || { echo "❌ mktemp failed" >&2; exit 1; } - if program_file "$_pd/program.dl" && { engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl"; }; then rm -rf "$_pd" - else rm -rf "$_pd"; exit 1; fi - printf '%s\n' "$ENGINE_ID" || exit 1 - exit 0;; - emit-program) program_file "$EMIT" || exit 1; exit 0;; -esac - -[ -n "${CLIENT:-}" ] && [ -n "${INT:-}" ] && [ -n "${OUT:-}" ] || { echo "usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L]" >&2; exit 1; } -FACTS="$INT/souffle-facts"; rm -rf "$FACTS"; mkdir -p "$FACTS" "$OUT" -# raw/ is OWNED: wiped per run so a relation that left the manifest cannot linger from an -# earlier run and be mistaken for this one's output. -RAW="$OUT/raw"; rm -rf "$RAW"; mkdir -p "$RAW" +set_cache_root(){ # Shared, machine-scoped cache root. Holds BOTH project-independent artefacts: the # compiled engine binary, and the staged library signature facts. # @@ -370,6 +358,165 @@ else || CACHE_ROOT="$SRC/../.souffle-cache" fi mkdir -p "$CACHE_ROOT" +} +# engine_binary: the engine binary for $PROG (whose id is $ENGINE_ID), in $BIN — the packaged one, the cached one, +# or one compiled here into the cache. Exits when there is none. Uses $INT for the generated C++. +engine_binary(){ +# What we cache is OUR engine compiled to a native binary (souffle -g turns the .dl rules +# into C++, c++ compiles it) — NOT the souffle tool. It depends only on the engine (rules + +# decls) and is PROJECT-INDEPENDENT (relative .input/.output), so one binary serves every +# project. It lives in a shared, machine-scoped cache keyed by the engine id — NOT in the +# per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is +# gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. +CACHE_DIR="$CACHE_ROOT" +# -march: `native` by default, tuned for the machine that compiles and runs it. A binary +# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs +# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline +# target instead, as the published engines are (build-engines.yml). Any other value is +# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a +# cache across machines keys it on the setting (ci.yml does). +case "${AXIOM_ENGINE_MARCH:-native}" in + portable) MARCH_FLAG=();; + *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; +esac +EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac +BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" + +# The platform string, in npm's spelling (process.platform-process.arch), because that is +# how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. +engine_platform(){ + local os arch + case "$(uname -s)" in + Linux) os=linux;; Darwin) os=darwin;; MINGW*|MSYS*|CYGWIN*) os=win32;; + *) echo "unsupported platform: $(uname -s)" >&2; return 1;; + esac + case "$(uname -m)" in + x86_64|amd64) arch=x64;; arm64|aarch64) arch=arm64;; + *) echo "unsupported architecture: $(uname -m)" >&2; return 1;; + esac + # a bash started from an Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64; npm installed + # the arm64 engine, and an arm64 binary runs natively even from a translated process. + if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; fi + printf '%s-%s\n' "$os" "$arch" +} +# 1. the engine package npm installed for this machine, if it was built from exactly these +# rules. Found by walking up from the package root the way node would, so a checkout's own +# node_modules and a global install both work. +PACKAGED="" +platform="$(engine_platform 2>/dev/null || true)" +# this machine's package first, then the same OS's other architecture: npm installs exactly one per machine, so when +# the first is absent the installed one is the one npm chose here. +if [ -n "$platform" ]; then + case "$platform" in *-arm64) other="${platform%-arm64}-x64";; *) other="${platform%-x64}-arm64";; esac + for p in "$platform" "$other"; do + d="$PKG" + while [ "$d" != / ] && [ ! -d "$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$p" ]; do d="$(dirname "$d")"; done + if [ "$d" != / ]; then platform="$p"; break; fi + done + d="$PKG" + while [ "$d" != / ]; do + pkgdir="$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$platform" + if [ -d "$pkgdir" ]; then + have="$(tr -d '[:space:]' < "$pkgdir/$LANG_ARG/ENGINE_ID" 2>/dev/null || true)" + cand="$pkgdir/$LANG_ARG/axiomcode-engine-$LANG_ARG$EXE" + if [ "$have" = "$ENGINE_ID" ] && [ -f "$cand" ]; then PACKAGED="$cand"; chmod +x "$cand" 2>/dev/null || true + elif [ -n "$have" ]; then echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform holds $LANG_ARG at ${have:0:12}…, these rules are ${ENGINE_ID:0:12}… — not using it (publish a new engine version for these rules)" + else echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform has no $LANG_ARG engine"; fi + break + fi + d="$(dirname "$d")" + done +fi + +if [ -n "$PACKAGED" ]; then + BIN="$PACKAGED"; echo "▶ using packaged engine $ENGINE_PACKAGE_SCOPE/engine-$platform ($LANG_ARG)" +elif [ -x "$BIN" ]; then + echo "▶ reusing cached binary" +elif command -v souffle >/dev/null 2>&1; then + # ONE COMPILE PER ENGINE ID, under a lock whose owner must be dead, not merely old, before + # another run takes it over (compile-lock.sh). + COMPILE_LOCK="$BIN.lock" + compile_lock_take "$COMPILE_LOCK" + trap 'compile_lock_drop "$COMPILE_LOCK"' EXIT +fi +if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then + echo "▶ reusing the binary another run compiled" +elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then + echo "▶ compiling souffle program (cache miss)..." + INNER="$(find_souffle_include)" + # Assert the HEADER, not the directory: `[ -d ]` is the test #216 established cannot tell + # the two install layouts apart, so it would pass a path that then fails at the compiler. + if [ -z "$INNER" ] || [ ! -f "$INNER/souffle/CompiledSouffle.h" ]; then + echo "❌ soufflé is on PATH but its headers are not. Set AXIOM_SOUFFLE_INCLUDE." >&2; exit 1 + fi + have="$(souffle --version 2>/dev/null | sed -n 's/^Version: *\([0-9][0-9.]*\).*/\1/p' | head -1)" + [ "$have" = "$SOUFFLE_VERSION" ] || echo " ! local souffle is $have, the pinned version is $SOUFFLE_VERSION — a locally compiled engine may differ from CI's" + # Generate C++. souffle's "No rules/facts defined" warnings (for the intentionally + # unstaged lib-body relations — inert paths) aren't silenced by -w, so filter those 3- + # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w + # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then + # atomically rename, so a concurrent/aborted run never leaves a half-written binary. + if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then + cat "$INT/.souffle-gen.log" >&2; exit 1 + fi + awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 + [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } + CXX_PLATFORM="" + case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 + fi + # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may + # land under $ENGINE_ID unless it is a whole binary built from the program that id names: + # the temp binary must be a non-empty executable, and the program must still hash to the id + # (a program or rule file that changed during the compile would otherwise be cached under + # the old id). Only then the atomic rename. + _built_id="$ENGINE_ID" + if [ ! -s "$BIN.tmp.$$" ] || [ ! -x "$BIN.tmp.$$" ] || ! engine_id_of "$PROG" || [ "$ENGINE_ID" != "$_built_id" ]; then + rm -f "$BIN.tmp.$$" + echo "❌ the compiled engine did not verify (program now hashes to ${ENGINE_ID:-nothing}, built as $_built_id); not caching it" >&2 + exit 1 + fi + mv -f "$BIN.tmp.$$" "$BIN" +fi +if [ -n "${COMPILE_LOCK:-}" ]; then compile_lock_drop "$COMPILE_LOCK"; trap - EXIT +elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then + echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 + echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 + echo " • install souffle $SOUFFLE_VERSION to compile locally (macOS: brew install souffle; Ubuntu: the .deb from souffle-lang/souffle releases)." >&2 + exit 1 +fi +} +case "$MODE" in + print-engine-id) + _pd="$(mktemp -d "${TMPDIR:-/tmp}/axiom-program.XXXXXX")" && [ -d "$_pd" ] || { echo "❌ mktemp failed" >&2; exit 1; } + if program_file "$_pd/program.dl" && { engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl" || engine_id_of "$_pd/program.dl"; }; then rm -rf "$_pd" + else rm -rf "$_pd"; exit 1; fi + printf '%s\n' "$ENGINE_ID" || exit 1 + exit 0;; + emit-program) program_file "$EMIT" || exit 1; exit 0;; + # The engine binary without a project: what an install or a build runs (`axiomcode prepare`), so the first index + # finds it cached instead of paying the C++ compile — minutes, against seconds for the index itself. The same + # program text, id, cache entry and lock as a run, so a run that starts meanwhile waits for this compile. + prepare) + set_cache_root + INT="$(mktemp -d "${TMPDIR:-/tmp}/axiom-prepare.XXXXXX")" && [ -d "$INT" ] || { echo "❌ mktemp failed" >&2; exit 1; } + PROG="$INT/souffle-program.dl"; _t0=$(date +%s) + program_file "$PROG" || { rm -rf "$INT"; exit 1; } + engine_id_of "$PROG" || engine_id_of "$PROG" || engine_id_of "$PROG" \ + || { rm -rf "$INT"; echo "❌ could not compute the engine id of $PROG" >&2; exit 1; } + engine_binary + rm -rf "$INT" + echo "✓ $LANG_ARG engine ready in $(( $(date +%s) - _t0 )) s: $BIN" + exit 0;; +esac + +[ -n "${CLIENT:-}" ] && [ -n "${INT:-}" ] && [ -n "${OUT:-}" ] || { echo "usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L]" >&2; exit 1; } +FACTS="$INT/souffle-facts"; rm -rf "$FACTS"; mkdir -p "$FACTS" "$OUT" +# raw/ is OWNED: wiped per run so a relation that left the manifest cannot linger from an +# earlier run and be mistaken for this one's output. +RAW="$OUT/raw"; rm -rf "$RAW"; mkdir -p "$RAW" +set_cache_root START_EPOCH=$(date +%s); START_TS=$(date '+%Y-%m-%d %H:%M:%S') # Library roots: --library is a comma-separated list of IR roots (each with jdk-style @@ -546,130 +693,7 @@ engine_id_of "$PROG" || engine_id_of "$PROG" || engine_id_of "$PROG" \ || { echo "❌ could not compute the engine id of $PROG; refusing to guess a cache entry" >&2; exit 1; } echo "▶ engine id = $ENGINE_ID (rules + souffle $SOUFFLE_VERSION)" -# What we cache is OUR engine compiled to a native binary (souffle -g turns the .dl rules -# into C++, c++ compiles it) — NOT the souffle tool. It depends only on the engine (rules + -# decls) and is PROJECT-INDEPENDENT (relative .input/.output), so one binary serves every -# project. It lives in a shared, machine-scoped cache keyed by the engine id — NOT in the -# per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is -# gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. -CACHE_DIR="$CACHE_ROOT" -# -march: `native` by default, tuned for the machine that compiles and runs it. A binary -# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs -# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline -# target instead, as the published engines are (build-engines.yml). Any other value is -# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a -# cache across machines keys it on the setting (ci.yml does). -case "${AXIOM_ENGINE_MARCH:-native}" in - portable) MARCH_FLAG=();; - *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; -esac -EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac -BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" - -# The platform string, in npm's spelling (process.platform-process.arch), because that is -# how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. -engine_platform(){ - local os arch - case "$(uname -s)" in - Linux) os=linux;; Darwin) os=darwin;; MINGW*|MSYS*|CYGWIN*) os=win32;; - *) echo "unsupported platform: $(uname -s)" >&2; return 1;; - esac - case "$(uname -m)" in - x86_64|amd64) arch=x64;; arm64|aarch64) arch=arm64;; - *) echo "unsupported architecture: $(uname -m)" >&2; return 1;; - esac - # a bash started from an Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64; npm installed - # the arm64 engine, and an arm64 binary runs natively even from a translated process. - if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; fi - printf '%s-%s\n' "$os" "$arch" -} -# 1. the engine package npm installed for this machine, if it was built from exactly these -# rules. Found by walking up from the package root the way node would, so a checkout's own -# node_modules and a global install both work. -PACKAGED="" -platform="$(engine_platform 2>/dev/null || true)" -# this machine's package first, then the same OS's other architecture: npm installs exactly one per machine, so when -# the first is absent the installed one is the one npm chose here. -if [ -n "$platform" ]; then - case "$platform" in *-arm64) other="${platform%-arm64}-x64";; *) other="${platform%-x64}-arm64";; esac - for p in "$platform" "$other"; do - d="$PKG" - while [ "$d" != / ] && [ ! -d "$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$p" ]; do d="$(dirname "$d")"; done - if [ "$d" != / ]; then platform="$p"; break; fi - done - d="$PKG" - while [ "$d" != / ]; do - pkgdir="$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$platform" - if [ -d "$pkgdir" ]; then - have="$(tr -d '[:space:]' < "$pkgdir/$LANG_ARG/ENGINE_ID" 2>/dev/null || true)" - cand="$pkgdir/$LANG_ARG/axiomcode-engine-$LANG_ARG$EXE" - if [ "$have" = "$ENGINE_ID" ] && [ -f "$cand" ]; then PACKAGED="$cand"; chmod +x "$cand" 2>/dev/null || true - elif [ -n "$have" ]; then echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform holds $LANG_ARG at ${have:0:12}…, these rules are ${ENGINE_ID:0:12}… — not using it (publish a new engine version for these rules)" - else echo " ! $ENGINE_PACKAGE_SCOPE/engine-$platform has no $LANG_ARG engine"; fi - break - fi - d="$(dirname "$d")" - done -fi - -if [ -n "$PACKAGED" ]; then - BIN="$PACKAGED"; echo "▶ using packaged engine $ENGINE_PACKAGE_SCOPE/engine-$platform ($LANG_ARG)" -elif [ -x "$BIN" ]; then - echo "▶ reusing cached binary" -elif command -v souffle >/dev/null 2>&1; then - # ONE COMPILE PER ENGINE ID, under a lock whose owner must be dead, not merely old, before - # another run takes it over (compile-lock.sh). - COMPILE_LOCK="$BIN.lock" - compile_lock_take "$COMPILE_LOCK" - trap 'compile_lock_drop "$COMPILE_LOCK"' EXIT -fi -if [ -z "$PACKAGED" ] && [ -x "$BIN" ] && [ -n "${COMPILE_LOCK:-}" ]; then - echo "▶ reusing the binary another run compiled" -elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then - echo "▶ compiling souffle program (cache miss)..." - INNER="$(find_souffle_include)" - # Assert the HEADER, not the directory: `[ -d ]` is the test #216 established cannot tell - # the two install layouts apart, so it would pass a path that then fails at the compiler. - if [ -z "$INNER" ] || [ ! -f "$INNER/souffle/CompiledSouffle.h" ]; then - echo "❌ soufflé is on PATH but its headers are not. Set AXIOM_SOUFFLE_INCLUDE." >&2; exit 1 - fi - have="$(souffle --version 2>/dev/null | sed -n 's/^Version: *\([0-9][0-9.]*\).*/\1/p' | head -1)" - [ "$have" = "$SOUFFLE_VERSION" ] || echo " ! local souffle is $have, the pinned version is $SOUFFLE_VERSION — a locally compiled engine may differ from CI's" - # Generate C++. souffle's "No rules/facts defined" warnings (for the intentionally - # unstaged lib-body relations — inert paths) aren't silenced by -w, so filter those 3- - # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w - # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then - # atomically rename, so a concurrent/aborted run never leaves a half-written binary. - if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then - cat "$INT/.souffle-gen.log" >&2; exit 1 - fi - awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 - [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } - CXX_PLATFORM="" - case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then - rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 - fi - # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may - # land under $ENGINE_ID unless it is a whole binary built from the program that id names: - # the temp binary must be a non-empty executable, and the program must still hash to the id - # (a program or rule file that changed during the compile would otherwise be cached under - # the old id). Only then the atomic rename. - _built_id="$ENGINE_ID" - if [ ! -s "$BIN.tmp.$$" ] || [ ! -x "$BIN.tmp.$$" ] || ! engine_id_of "$PROG" || [ "$ENGINE_ID" != "$_built_id" ]; then - rm -f "$BIN.tmp.$$" - echo "❌ the compiled engine did not verify (program now hashes to ${ENGINE_ID:-nothing}, built as $_built_id); not caching it" >&2 - exit 1 - fi - mv -f "$BIN.tmp.$$" "$BIN" -fi -if [ -n "${COMPILE_LOCK:-}" ]; then compile_lock_drop "$COMPILE_LOCK"; trap - EXIT -elif [ -z "$PACKAGED" ] && [ ! -x "$BIN" ]; then - echo "❌ no engine for $LANG_ARG@${ENGINE_ID:0:12}… on this machine. Either:" >&2 - echo " • run \`npm install\` here — it fetches $ENGINE_PACKAGE_SCOPE/engine- for this machine (if these rules have been published), or" >&2 - echo " • install souffle $SOUFFLE_VERSION to compile locally (macOS: brew install souffle; Ubuntu: the .deb from souffle-lang/souffle releases)." >&2 - exit 1 -fi +engine_binary # --- STAGE↔SOLVE loop: solve → stage the bodies of methods reached so far → re-solve, until # reachable_method stops growing. Soufflé loads facts up front and can't fetch bodies mid- # solve, so the driver feeds them in reachability order. Each round loads the bodies of ALL diff --git a/graph/test/tools/engine-package-test.sh b/graph/test/tools/engine-package-test.sh index a4bbd62c..eec6de84 100755 --- a/graph/test/tools/engine-package-test.sh +++ b/graph/test/tools/engine-package-test.sh @@ -61,4 +61,40 @@ if run; then bad "a run with no engine package and no souffle succeeded"; else grep -q "npm install" "$W/log" || bad "the no-package error does not point at npm install" fi -if [ "$fail" -eq 0 ]; then echo "engine-package: ok (packaged engine by id, stale package refused, absence explained)"; else echo "engine-package: $fail failure(s)"; exit 1; fi +# 4-7. `--prepare` (what `axiomcode prepare` runs at build time) puts the binary where a run looks for it, so the first +# index reuses it instead of compiling. A stub souffle and c++ stand in for the real ones: c++ "compiles" the fake +# engine above and counts its calls. Control: a background prepare under CI compiles nothing. +fake="$W/fake-engine" +{ echo '#!/usr/bin/env bash' + echo 'while [ $# -gt 0 ]; do case "$1" in -D) D="$2"; shift 2;; -F) shift 2;; *) shift;; esac; done' + cut -f2 "$ROOT/graph/$lang/souffle/export_manifest.tsv" | sed 's|^|: > "$D/|; s|$|"|'; } > "$fake" +mkdir -p "$W/stub" "$W/inc/souffle"; : > "$W/inc/souffle/CompiledSouffle.h"; : > "$W/cc-calls" +{ echo '#!/usr/bin/env bash' + echo "[ \"\$1\" = --version ] && { echo 'Version: $SOUFFLE_VERSION'; exit 0; }" + echo 'while [ $# -gt 0 ]; do case "$1" in -g) : > "$2"; echo "// c++" > "$2"; shift 2;; *) shift;; esac; done'; } > "$W/stub/souffle" +{ echo '#!/usr/bin/env bash' + echo "echo x >> '$W/cc-calls'" + echo 'while [ $# -gt 0 ]; do case "$1" in -o) o="$2"; shift 2;; *) shift;; esac; done' + echo "cp '$fake' \"\$o\"; chmod +x \"\$o\""; } > "$W/stub/c++" +chmod +x "$W/stub/souffle" "$W/stub/c++" +prep(){ PATH="$W/stub:$SANDBOX_PATH" AXIOM_SOUFFLE_INCLUDE="$W/inc" AXIOM_SOUFFLE_CACHE="$W/cache" bash "$RUN" --language $lang --prepare > "$W/log" 2>&1; } +calls(){ wc -l < "$W/cc-calls" | tr -d ' '; } +if prep; then + [ -x "$W/cache/souffle-engine-$lang-$id" ] || bad "prepare left no binary under the run's cache name (souffle-engine-$lang-${id:0:12}…)" + [ "$(calls)" = 1 ] || bad "prepare compiled $(calls) time(s), expected 1" + grep -q "engine ready" "$W/log" || bad "prepare did not report the engine ready" +else bad "prepare failed:"; tail -8 "$W/log" | sed 's/^/ /'; fi +prep || bad "a second prepare failed" +grep -q "reusing cached binary" "$W/log" && [ "$(calls)" = 1 ] || bad "a second prepare compiled again ($(calls) compiles)" +# the run that follows: no souffle, no package, only the prepared binary — and it is used, not recompiled +rm -rf "$W/out" +if run; then grep -q "reusing cached binary" "$W/log" || bad "the run after prepare did not reuse the prepared binary" +else bad "the run after prepare failed:"; tail -8 "$W/log" | sed 's/^/ /'; fi +# control: the build's background prepare is skipped under CI, so nothing is compiled +rm -rf "$W/cache" +out="$(PATH="$W/stub:$SANDBOX_PATH" CI=1 AXIOM_SOUFFLE_CACHE="$W/cache" bash "$ROOT/bin/axiomcode" prepare --language $lang --background 2>&1)" +sleep 1 +case "$out" in *"not prepared"*) ;; *) bad "background prepare under CI did not say it was skipped: $out";; esac +[ "$(calls)" = 1 ] && [ ! -e "$W/cache/souffle-engine-$lang-$id" ] || bad "background prepare under CI compiled anyway" + +if [ "$fail" -eq 0 ]; then echo "engine-package: ok (packaged engine by id, stale package refused, absence explained, prepared binary reused)"; else echo "engine-package: $fail failure(s)"; exit 1; fi diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..208bd4bc --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/bus.ts @@ -0,0 +1,57 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/graph/test/typescript/cases/86-keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const; diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.edges b/graph/test/typescript/expected/86-keyed-callback-registry.edges new file mode 100644 index 00000000..38603009 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.edges @@ -0,0 +1,60 @@ +ambiguous_unknown CONSTRUCTOR_CALL Bus#subscribe(string,Handler) @L12 -> - +ambiguous_unknown CONSTRUCTOR_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L47 -> - +ambiguous_unknown CONSTRUCTOR_CALL bus#() @L7 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#(?) @L55 -> - +ambiguous_unknown FUNCTION_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL Bus#() @L17 -> - +ambiguous_unknown METHOD_CALL Bus#count(string) @L31 -> - +ambiguous_unknown METHOD_CALL Bus#dispatch(string,unknown) @L40 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L10 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L13 -> - +ambiguous_unknown METHOD_CALL Bus#subscribe(string,Handler) @L15 -> - +ambiguous_unknown METHOD_CALL Bus#subscribeAll(Record) @L23 -> - +ambiguous_unknown METHOD_CALL Indexer#dropped(unknown) @L30 -> - +ambiguous_unknown METHOD_CALL Indexer#indexed(unknown) @L27 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L54 -> - +ambiguous_unknown METHOD_CALL MiniBus#fire(string,unknown) @L55 -> - +ambiguous_unknown METHOD_CALL MiniBus#on(string,(p: unknown) =) @L50 -> - +ambiguous_unknown METHOD_CALL app#onA() @L55 -> - +ambiguous_unknown METHOD_CALL app#onAll() @L58 -> - +ambiguous_unknown METHOD_CALL app#onAnything(unknown) @L13 -> - +ambiguous_unknown METHOD_CALL app#onB() @L61 -> - +ambiguous_unknown METHOD_CALL app#onCreated(unknown) @L7 -> - +ambiguous_unknown METHOD_CALL app#onRemoved(unknown) @L10 -> - +callback_registered METHOD_CALL MiniBus#fire(string,unknown) @L55 -> MiniBus#(?) +event_dispatch METHOD_CALL app#create() @L40 -> Indexer#(?) +event_dispatch METHOD_CALL app#create() @L40 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#create() @L40 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#go() @L67 -> app#onA() +event_dispatch METHOD_CALL app#go() @L67 -> app#onAll() +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> Indexer#(?) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onCreated(unknown) +event_dispatch METHOD_CALL app#relay({ type: string }) @L47 -> app#onRemoved(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> Indexer#(?) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onAnything(unknown) +event_dispatch METHOD_CALL app#remove() @L43 -> app#onRemoved(unknown) +known_edge CONSTRUCTOR_CALL app#() @L37 -> Indexer#(Bus) +known_edge CONSTRUCTOR_CALL app#() @L4 -> Bus#() +known_edge CONSTRUCTOR_CALL app#() @L53 -> MiniBus#() +known_edge FUNCTION_CALL Bus#dispatch(string,unknown) @L41 -> bus#(unknown) +known_edge FUNCTION_CALL Bus#publish(string,unknown) @L36 -> Bus#run() +known_edge METHOD_CALL Bus#run() @L35 -> Bus#dispatch(string,unknown) +known_edge METHOD_CALL Bus#subscribeAll(Record) @L25 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL Indexer#(?) @L21 -> Indexer#indexed(unknown) +known_edge METHOD_CALL Indexer#(?) @L22 -> Indexer#dropped(unknown) +known_edge METHOD_CALL Indexer#start() @L20 -> Bus#subscribeAll(Record) +known_edge METHOD_CALL app#() @L34 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L35 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L36 -> Bus#subscribe(string,Handler) +known_edge METHOD_CALL app#() @L37 -> Indexer#start() +known_edge METHOD_CALL app#() @L63 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L64 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#() @L65 -> MiniBus#on(string,(p: unknown) =) +known_edge METHOD_CALL app#create() @L40 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#go() @L67 -> MiniBus#fire(string,unknown) +known_edge METHOD_CALL app#relay({ type: string }) @L47 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#remove() @L43 -> Bus#publish(string,unknown) +known_edge METHOD_CALL app#size() @L50 -> Bus#count(string) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.entries b/graph/test/typescript/expected/86-keyed-callback-registry.entries new file mode 100644 index 00000000..afec440b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + exported_from_entry_module app#create app.ts:39 + exported_from_entry_module app#go app.ts:66 + exported_from_entry_module app#onA app.ts:54 + exported_from_entry_module app#onAll app.ts:57 + exported_from_entry_module app#onAnything app.ts:12 + exported_from_entry_module app#onB app.ts:60 + exported_from_entry_module app#onCreated app.ts:6 + exported_from_entry_module app#onRemoved app.ts:9 + exported_from_entry_module app#relay app.ts:46 + exported_from_entry_module app#remove app.ts:42 + exported_from_entry_module app#size app.ts:49 + unimported_module app# app.ts:1 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.envelope b/graph/test/typescript/expected/86-keyed-callback-registry.envelope new file mode 100644 index 00000000..0a65686b --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.envelope @@ -0,0 +1 @@ +value bus#@9:41 -> bus#Bus.@16 diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields b/graph/test/typescript/expected/86-keyed-callback-registry.fields new file mode 100644 index 00000000..02ad1620 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields @@ -0,0 +1,12 @@ +ambiguous_unknown read Bus#count(string) -> - +ambiguous_unknown read Indexer#start() -> - +ambiguous_unknown read app#() -> - +ambiguous_unknown read app#create() -> - +ambiguous_unknown read app#size() -> - +known_edge read Bus#count(string) -> Bus#handlers +known_edge read Bus#dispatch(string,unknown) -> Bus#handlers +known_edge read Bus#subscribe(string,Handler) -> Bus#handlers +known_edge read Indexer#start() -> Indexer#b +known_edge read MiniBus#fire(string,unknown) -> MiniBus#h +known_edge read MiniBus#on(string,(p: unknown) =) -> MiniBus#h +known_edge read app#relay({ type: string }) -> { type: string }#type diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle new file mode 100644 index 00000000..9148ff26 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle @@ -0,0 +1,14 @@ +86-keyed-callback-registry [fields] + precision 0.8571 (6 correct, 1 wrong) + recall 0.5000 (6 of 12 the compiler resolved) + sites 17 resolved 11 (64.7%) + tiers ambiguous_unknown=6 known_edge=11 + access read=17 + not scored: 6 rows whose target is not a client declaration + WRONG app#relay({ type: string }) READ { type: string }#type + MISSING Indexer#start() READ topics#created + MISSING Indexer#start() READ topics#removed + MISSING app#() READ topics#created + MISSING app#create() READ topics#created + MISSING app#relay({ type: string }) READ app#type + MISSING app#size() READ topics#created diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.known-missing b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing new file mode 100644 index 00000000..5a8e9f31 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.known-missing @@ -0,0 +1,10 @@ +# Accepted gaps for 86-keyed-callback-registry — 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. +# +# The call through an element of a Set held in a Map resolves, for the compiler, to the element's +# function type; with no standard library staged the engine cannot type the Set's elements, so the +# call site stays unresolved (Bus.dispatch annotates its array, so its call resolves). The functions that call RUNS are reached by the keyed-registry edges +# (event_dispatch) from each publisher, which is what this case is about. +MiniBus#(?) -> MiniBus#(unknown) +MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.oracle b/graph/test/typescript/expected/86-keyed-callback-registry.oracle new file mode 100644 index 00000000..ee8df0d7 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.oracle @@ -0,0 +1,3 @@ +oracle=20 engine=18 agree=18 missing=2 (known 2, NEW 0) extra=0 + known MiniBus#(?) -> MiniBus#(unknown) + known MiniBus#fire(string,unknown) -> MiniBus#(unknown) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.type-use b/graph/test/typescript/expected/86-keyed-callback-registry.type-use new file mode 100644 index 00000000..1f405a87 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.type-use @@ -0,0 +1,14 @@ +ambiguous_unknown AS_TARGET 0 topics [EXPRESSION] -> - +ambiguous_unknown METHOD_PARAM 0 Bus [METHOD_PARAM] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 Bus [EXPRESSION] -> - +ambiguous_unknown METHOD_TYPE_ARGUMENT 0 MiniBus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 Bus [EXPRESSION] -> - +ambiguous_unknown OBJECT_CREATION_TYPE 0 MiniBus [EXPRESSION] -> - +known_edge METHOD_PARAM 0 Bus [METHOD_PARAM] -> Handler +known_edge METHOD_PARAM 0 Indexer [METHOD_PARAM] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Bus +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> Indexer +known_edge OBJECT_CREATION_TYPE 0 app [EXPRESSION] -> MiniBus +known_edge TYPE_ARGUMENT 1 Bus [EXPRESSION] -> Handler +known_edge TYPE_ARGUMENT 1 Bus [METHOD_PARAM] -> Handler +known_edge TYPE_ELEMENT 1 Bus [VARIABLE] -> Handler diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle new file mode 100644 index 00000000..82d34b05 --- /dev/null +++ b/graph/test/typescript/expected/86-keyed-callback-registry.types-oracle @@ -0,0 +1,7 @@ +86-keyed-callback-registry [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 16 resolved 8 (50.0%) + tiers ambiguous_unknown=8 known_edge=8 + contexts AS_TARGET=1 METHOD_PARAM=3 METHOD_TYPE_ARGUMENT=2 OBJECT_CREATION_TYPE=7 TYPE_ARGUMENT=2 TYPE_ELEMENT=1 + not scored: 8 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py index 291ccaa4..9807d757 100644 --- a/graph/test/typescript/tools/normalize_edges.py +++ b/graph/test/typescript/tools/normalize_edges.py @@ -283,8 +283,9 @@ def main(): if f[1] not in n.m or f[3] not in n.m: continue # a function the site HANDS OVER (`xs.map(cb)`) is not a call the compiler lists at that - # site: the pairs are scored against the compiler, the .edges golden still carries it - if f[5] == 'callback_registered': + # site, nor is the handler a publish reaches through a keyed registry (`event_dispatch`): the + # pairs are scored against the compiler, the .edges golden still carries both + if f[5] in ('callback_registered', 'event_dispatch'): continue seen.add(f"{n.label(f[1])} -> {n.label(f[3])}") else: diff --git a/graph/typescript/engine/call-edge-generation/keyed_registry.dl b/graph/typescript/engine/call-edge-generation/keyed_registry.dl new file mode 100644 index 00000000..b1eabc1d --- /dev/null +++ b/graph/typescript/engine/call-edge-generation/keyed_registry.dl @@ -0,0 +1,267 @@ +// ============================================================================ +// CALL-EDGE-GEN · A CALLBACK KEPT IN A KEYED REGISTRY (the in-process bus) +// +// An in-process publish/subscribe is a class that keeps functions in a field keyed by a +// name and runs the ones under a name when that name is published: +// +// subscribe(name, fn) { (this.handlers.get(name) ?? …).add(fn); } +// publish(name, p) { for (const h of this.handlers.get(name) ?? []) h(p); } +// bus.subscribe('order.placed', onPlaced); … bus.publish('order.placed', o); +// +// Neither end calls the other. The call inside `publish` goes through a variable the +// registry filled, and the only rule that reached a handler from there was the function-type +// envelope (value-flow.dl, typed_holder_signature): every function ever passed where the +// handler type is expected became a candidate of that type's signature, so a publish on one +// name reached the handlers of every name, and of every other registry typed the same way. +// +// What ties the two ends together is the KEY both callers write. So the join is made between +// the CALLERS of the two methods, as the messaging rules join a producer to a consumer +// (framework-behavior/destinations.dl), but in process and on the project's own class: +// +// * a STORE: a method puts its function parameter into a field under its key parameter — +// `slot.add(fn)` / `push` / `unshift` on the slot `this.F.get(k)` or `this.F[k]` (directly, +// through `??`, `!`, `as`, or a variable it initialises), `this.F.set(k, fn)`, `this.F[k] = fn`; +// * a DISPATCH: a method reads the same field under its key parameter and CALLS what it gets — +// `for (const h of slot)`, a spread of the slot into an array it iterates, `slot.forEach(h => h())`. +// A read that calls nothing (`this.F.get(k)?.size`) is not a dispatch; +// * each is followed through wrappers that pass their own parameter on (`publish` forwarding the +// name to a private `dispatch`, from inside a closure too), and a method that subscribes each +// entry of an object parameter under its own key (`subscribeAll({ [K]: fn })`) registers every +// entry of the literal its callers pass; +// * a key is a string literal, a const holding one, or a member of a const object literal +// (`as const` too), local or imported. A key known only at run time matches every key. A constant +// key the dispatch ALSO looks up (`this.F.get('*')`) makes a registration under it match every +// publish. Not destinations.dl's rd_val, which reads more: that reader is fed by call_chain_edge +// (its wrapper hop) and negates inside, so an edge here built on it could not be stratified. +// +// The edge runs from the PUBLISH call site to the registered function, tier event_dispatch: the +// site does not call the handler, the registry does, as the JavaScript engine records an emitter's +// `emit` reaching an `on` handler. Receivers are not told apart: two instances of one bus class +// share its registrations, the precision the class gives. +// +// And the envelope row that made every handler a candidate of the handler type is withdrawn for the +// store's function parameter, but only where the registry has a dispatch this file recognised — +// otherwise it is the only route there is. +// ============================================================================ + +.decl kr_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_read(c0:symbol, c1:symbol, c2:symbol) +.decl kr_yields(c0:symbol, c1:symbol) +.decl kr_slot(c0:symbol, c1:symbol, c2:symbol) +.decl kr_adds(c0:symbol) +.decl kr_store(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_elem_holder(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs(c0:symbol, c1:symbol, c2:symbol) +.decl kr_runs_param(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatch_lit(c0:symbol, c1:symbol, c2:symbol) +.decl kr_dispatched(c0:symbol) +.decl kr_site_runs(c0:symbol, c1:symbol) +.decl kr_disp(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_reg(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl kr_table_elem(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table(c0:symbol, c1:symbol, c2:symbol) +.decl kr_key_arg(c0:symbol) +.decl kr_keyed(c0:symbol) +.decl kr_keyval(c0:symbol, c1:symbol) +.decl kr_fn(c0:symbol, c1:symbol) +.decl kr_forwards(c0:symbol) +.decl kr_registers(c0:symbol, c1:symbol, c2:symbol) +.decl kr_table_lit(c0:symbol) +.decl kr_at(c0:symbol, c1:number, c2:number) +.decl kr_entry(c0:symbol, c1:symbol, c2:symbol) +.decl kr_static_key(c0:symbol, c1:symbol) +.decl kr_computed_root(c0:symbol, c1:symbol) +.decl kr_key_candidate(c0:symbol, c1:symbol, c2:number) +.decl kr_entry_key(c0:symbol, c1:symbol) +.decl kr_dispatch_site(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) +.decl kr_match(c0:symbol, c1:symbol, c2:symbol, c3:symbol) +.decl keyed_dispatch(c0:symbol, c1:symbol) +.decl kr_registry_param(c0:symbol) + +// an expression that names a parameter: (expr, the function declaring it, its position) +kr_param(e, owner, pos) :- expr_referenced("client", "PARAMETER", p, e), param_decl("client", _, pos, _, owner, p). + +// ── the registry, read under a key: `this.F.get(k)`, `this.F[k]` ───────────── +kr_read(ce, f, k) :- call_site("client", _, "get", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k). +kr_read(e, f, k) :- expr_kind("client", "ELEMENT_ACCESS", _, e), expr_child("client", e, "RECEIVER", _, r), + ts_field_access_target(r, f), expr_child("client", e, "INDEX_ARGUMENT", _, k). + +// kr_yields(X, Read): iterating or adding to X reaches the collection Read found: X is the read, +// an operand `??` / `||` / `&&` may yield, a transparent wrapper, or an array spreading it +kr_yields(e, e) :- kr_read(e, _, _). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", "BINARY_EXPRESSION", _, p), + expr_operator("client", op, p), binary_op_yields_operand(op, r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, r, _, c), expr_kind("client", k, _, p), + expr_kind_is_transparent(k), edge_role_is_operand(r). +kr_yields(p, e) :- kr_yields(c, e), expr_child("client", p, "SPREAD_OPERAND", _, c), expr_kind("client", "SPREAD_ELEMENT", _, p). +kr_yields(p, e) :- kr_yields(c, e), expr_kind("client", "SPREAD_ELEMENT", _, c), expr_child("client", p, "ARRAY_ELEMENT", _, c). +// kr_slot(X, F, K): X holds the collection registry F keeps under key expression K +kr_slot(x, f, k) :- kr_yields(x, e), kr_read(e, f, k). +kr_slot(x, f, k) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_slot(i, f, k). + +// ── the store: (method, key parameter position, function parameter position, field) ── +kr_adds("add"). +kr_adds("push"). +kr_adds("unshift"). +kr_store(s, kp, fp, f) :- call_site("client", _, mn, _, recv, ce, _), kr_adds(mn), recv != "", + kr_slot(recv, f, k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "0", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- call_site("client", _, "set", _, recv, ce, _), recv != "", ts_field_access_target(recv, f), + expr_child("client", ce, "ARGUMENT", "0", k), kr_param(k, s, kp), + expr_child("client", ce, "ARGUMENT", "1", a), kr_param(a, s, fp). +kr_store(s, kp, fp, f) :- expr_kind("client", "ASSIGNMENT_EXPRESSION", _, asg), + expr_child("client", asg, "LEFT_OPERAND", _, t), kr_read(t, f, k), kr_param(k, s, kp), + expr_child("client", asg, "RIGHT_OPERAND", _, a), kr_param(a, s, fp). + +// ── the dispatch: a method calls what the registry holds under a key ───────── +// kr_elem_holder(H, F, K): the variable or parameter H is bound to an element of the slot +kr_elem_holder(v, f, k) :- for_binding_iterable(v, it), kr_slot(it, f, k). +kr_elem_holder(p, f, k) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +kr_elem_holder(p, f, k) :- call_site("client", "OPTIONAL_CALL", n, _, recv, ce, _), recv != "", kr_slot(recv, f, k), + array_method_element_param(n, pos), expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), param_decl("client", _, pos, _, cb, p). +// (method that reads the key, field, key expression) — a call is made through the element +kr_runs(d, f, k) :- kr_elem_holder(h, f, k), called_through(_, h), kr_read(e, f, k), expr_enclosing_method(e, d). +// the key is a parameter: whoever calls its function names the key +kr_runs_param(owner, pos, f) :- kr_runs(_, f, k), kr_param(k, owner, pos). +kr_dispatched(f) :- kr_runs(_, f, _). +// the key is a constant the dispatch also looks up (`this.F.get(ANY)`) +kr_demand(k) :- kr_runs(_, _, k), !kr_param(k, _, _). +kr_dispatch_lit(d, f, v) :- kr_runs(d, f, k), !kr_param(k, _, _), kr_str(k, v). + +// ── through wrappers ───────────────────────────────────────────────────────── +kr_site_runs(ce, m) :- expr_resolves_to_method(ce, m). +kr_site_runs(ce, m) :- call_runs_method(ce, m). +// kr_disp(W, Pos, F, D): calling W with a key at Pos runs, in D, what F holds under it +kr_disp(o, pos, f, d) :- kr_runs_param(o, pos, f), kr_runs(d, f, k), kr_param(k, o, pos). +kr_disp(w, pos, f, d) :- kr_disp(w2, p2, f, d), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), w != w2. +// kr_reg(W, KeyPos, FnPos, F): calling W stores the function at FnPos in F under the key at KeyPos +kr_reg(s, kp, fp, f) :- kr_store(s, kp, fp, f). +kr_reg(w, kp2, fp2, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), kr_param(ka, w, kp2), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_param(fa, w, fp2), w != s. +// kr_table(W, Pos, F): W stores each entry of the object at Pos under the entry's key: +// the function it stores is `table[k]` of that parameter, directly or through a const +kr_table_elem(x, w, tp) :- expr_kind("client", "ELEMENT_ACCESS", _, x), expr_child("client", x, "RECEIVER", _, r), + kr_param(r, w, tp). +kr_table_elem(x, w, tp) :- expr_referenced("client", "VARIABLE", v, x), var_initializer("client", _, i, v), i != "", + kr_table_elem(i, w, tp). +kr_table(w, tp, f) :- kr_reg(s, kp, fp, f), kr_site_runs(oc, s), + expr_child("client", oc, "ARGUMENT", kp, ka), !expr_kind("client", "LITERAL", _, ka), + expr_child("client", oc, "ARGUMENT", fp, fa), kr_table_elem(fa, w, tp). + +// ── the string a key expression holds (on demand) ──────────────────────────── +.decl kr_demand(c0:symbol) +.decl kr_str(c0:symbol, c1:symbol) +.decl kr_denotes_var(c0:symbol, c1:symbol) +.decl kr_objlit(c0:symbol, c1:symbol) +.decl kr_obj(c0:symbol, c1:symbol) +.decl kr_member(c0:symbol, c1:symbol) +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "VARIABLE", v, e). +kr_denotes_var(e, v) :- kr_demand(e), expr_referenced("client", "IMPORT_BINDING", ih, e), import_binds(ih, "client", "VARIABLE", v). +kr_str(e, v) :- kr_demand(e), expr_kind("client", "LITERAL", _, e), expr_literal_type("client", "STRING", e), + expr_literal_value("client", v, e). +// a const holding it +kr_demand(i) :- kr_denotes_var(_, v), var_initializer("client", _, i, v), i != "". +kr_str(e, s) :- kr_denotes_var(e, v), var_initializer("client", _, i, v), i != "", kr_str(i, s). +// `x as const`, `x!` +kr_demand(c) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r). +kr_str(e, s) :- kr_demand(e), expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, r, _, c), + edge_role_is_operand(r), kr_str(c, s). +// `TOPIC.created` over `const TOPIC = { created: '…' } as const` +kr_objlit(i, i) :- expr_kind("client", "OBJECT_LITERAL", _, i). +kr_objlit(i, l) :- expr_kind("client", k, _, i), expr_kind_is_transparent(k), expr_child("client", i, r, _, c), + edge_role_is_operand(r), kr_objlit(c, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "VARIABLE", v, q), var_initializer("client", _, i, v), kr_objlit(i, l). +kr_obj(q, l) :- kr_demand(e), property_access_name(e, _), expr_child("client", e, "RECEIVER", _, q), + expr_referenced("client", "IMPORT_BINDING", ih, q), import_binds(ih, "client", "VARIABLE", v), + var_initializer("client", _, i, v), kr_objlit(i, l). +kr_member(e, val) :- kr_demand(e), property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), kr_obj(q, l), + expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), expr_literal_value("client", n, kk), + expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, val). +kr_demand(val) :- kr_member(_, val). +kr_str(e, s) :- kr_member(e, val), kr_str(val, s). + +// ── the key a call writes ──────────────────────────────────────────────────── +kr_key_arg(a) :- kr_reg(s, kp, _, _), kr_site_runs(oc, s), expr_child("client", oc, "ARGUMENT", kp, a). +kr_key_arg(a) :- kr_disp(w, pos, _, _), kr_site_runs(oc, w), expr_child("client", oc, "ARGUMENT", pos, a). +kr_demand(a) :- kr_key_arg(a). +kr_keyed(a) :- kr_key_arg(a), kr_str(a, _). +kr_keyval(a, v) :- kr_key_arg(a), kr_str(a, v). +kr_keyval(a, "{}") :- kr_key_arg(a), !kr_keyed(a). + +// the function an argument hands over where it is written: a function, an arrow, an import, +// `this.onX`, `onX.bind(this)`, or a const holding one. A parameter passed on is not: the +// wrapper that passes it is followed instead (kr_reg), so its callers are the registrations. +kr_fn(x, m) :- expr_callable(x, m), !expr_kind("client", "CALL_EXPRESSION", _, x). +kr_fn(x, m) :- method_value(x, m). +kr_fn(x, m) :- expr_referenced("client", "VARIABLE", v, x), holder_holds_function(v, m), !kr_table_elem(x, _, _). +// (`const fn = table[name]` inside a wrapper that files each entry is a table hop, kr_table, not a +// registration of every entry under a key only known at run time) + +// a call inside a wrapper that passes its own parameter on is a hop of the wrapper, not a site +kr_forwards(oc) :- kr_disp(w2, p2, _, _), kr_site_runs(oc, w2), expr_child("client", oc, "ARGUMENT", p2, a), + kr_param(a, w, pos), kr_disp(w, pos, _, _). + +// ── registrations: (field, key, function) ──────────────────────────────────── +kr_registers(f, kv, m) :- kr_reg(s, kp, fp, f), kr_site_runs(rc, s), + expr_child("client", rc, "ARGUMENT", kp, ka), kr_keyval(ka, kv), + expr_child("client", rc, "ARGUMENT", fp, fa), kr_fn(fa, m). +// a table: each entry of the object literal passed (or the const it initialises) +kr_table_lit(l) :- kr_table(w, tp, _), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l). +.decl value_branch_lit(c0:symbol, c1:symbol) +value_branch_lit(a, a) :- expr_kind("client", "OBJECT_LITERAL", _, a). +value_branch_lit(a, l) :- expr_referenced("client", "VARIABLE", v, a), var_initializer("client", _, l, v), + expr_kind("client", "OBJECT_LITERAL", _, l). +kr_entry(l, pos, v) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_VALUE", pos, v). +kr_static_key(l, pos) :- kr_table_lit(l), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, _). +kr_entry_key(v, kv) :- kr_entry(l, pos, v), expr_child("client", l, "OBJECT_PROPERTY_KEY", pos, kk), + expr_literal_value("client", kv, kk). +// A COMPUTED key (`[TOPIC.created]: fn`) is its own root in the IR, with no link to its property +// (the walker reaches it as COMPUTED_PROPERTY_NAME). It is the last computed-name root of the same +// module that starts inside the literal and ends before the entry's value begins. +kr_at(e, to_number(sl) * 100000 + to_number(sc), to_number(el) * 100000 + to_number(ec)) :- + expr_location("client", sl, sc, el, ec, e), (kr_table_lit(e) ; kr_entry(_, _, e) ; kr_computed_root(e, _)). +kr_computed_root(c, mod) :- expr_root_context("client", "COMPUTED_PROPERTY_NAME", c), expr_kind("client", _, "ROOT", c), + expr_module("client", mod, c). +kr_key_candidate(v, c, cs) :- kr_entry(l, pos, v), !kr_static_key(l, pos), expr_module("client", mod, l), + kr_computed_root(c, mod), kr_at(l, ls, le), kr_at(c, cs, ce), kr_at(v, vs, _), + cs > ls, ce <= vs, vs < le. +kr_entry_key(v, kv) :- kr_key_candidate(v, c, cs), cs = max s : { kr_key_candidate(v, _, s) }, + kr_str(c, kv). +kr_demand(c) :- kr_key_candidate(_, c, _). +kr_registers(f, kv, m) :- kr_table(w, tp, f), kr_site_runs(rc, w), expr_child("client", rc, "ARGUMENT", tp, a), + value_branch_lit(a, l), kr_entry(l, _, v), kr_entry_key(v, kv), kr_fn(v, m). + +// ── the join ───────────────────────────────────────────────────────────────── +kr_dispatch_site(dc, from, f, d, kv) :- kr_disp(w, pos, f, d), kr_site_runs(dc, w), !kr_forwards(dc), + expr_child("client", dc, "ARGUMENT", pos, a), kr_keyval(a, kv), call_from(dc, from). +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = kr. +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kd = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr = "{}". +kr_match(kd, kr, f, d) :- kr_dispatch_site(_, _, f, d, kd), kr_registers(f, kr, _), kr_dispatch_lit(d, f, kr). +keyed_dispatch(dc, m) :- kr_dispatch_site(dc, _, f, d, kd), kr_registers(f, kr, m), kr_match(kd, kr, f, d). + +call_chain_edge(dc, caller, "-", m, "client", "event_dispatch", kind) :- keyed_dispatch(dc, m), + method_prov(m, "client"), call_from(dc, caller), caller != m, invocation_site(dc, kind). + +// the store's function parameter, in a registry this file dispatches: the envelope row for it +// is withdrawn (value-flow.dl, typed_holder_signature) — on the method, on each overload signature +// a call selects instead of it, and on a wrapper that passes its own parameter on +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), param_decl("client", _, fp, _, s, p). +kr_registry_param(p) :- kr_reg(s, _, fp, f), kr_dispatched(f), group_implementation(g, s), method_group_of(sig, g), + param_decl("client", _, fp, _, sig, p). + +// A registry's slot is a collection: `slot.add(fn)`, `slot.delete(fn)` store or drop the function and +// never call it, as for a Map or Set the types name (value-flow.dl, collection_store_site). Without +// the library staged the slot has no type, and every function ever subscribed was "handed" to the +// store and reached from every caller of subscribe. +collection_store_site(ce) :- call_site("client", _, mn, _, recv, ce, _), recv != "", mn != "forEach", kr_slot(recv, _, _). diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 442303de..496892d3 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -406,7 +406,10 @@ ref_call_signature(r, sig) :- ref_type_target(r, _, t), call_signature_in_scope( typed_holder_signature(h, sig) :- field_type_ref("client", r, h), ref_call_signature(r, sig). typed_holder_signature(h, sig) :- var_type_ref("client", r, h), ref_call_signature(r, sig). -typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig). +// …except the function parameter of a method that files it in a keyed registry a dispatch reads: +// what runs it is the publish on the same key (call-edge-generation/keyed_registry.dl), and the +// type would hand it to every call of that type, whatever the key +typed_holder_signature(h, sig) :- param_type_ref("client", r, h), ref_call_signature(r, sig), !kr_registry_param(h). method_dispatch_candidate(sig, m, "value") :- typed_holder_signature(h, sig), holder_holds_function(h, m), diff --git a/package.json b/package.json index 15a3a4e8..4fd812a5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clean": "rm -rf dist", "prebuild": "npm run clean", "build": "npm --prefix parser run build && tsc && tsc-alias", + "postbuild": "node bin/axiomcode.js prepare --background || echo \"engines not prepared; the first index compiles them\"", "prepare": "npm run build", "typecheck": "tsc --noEmit", "schema-doc": "tsx graph/bundle/cli.ts --print-schema > graph/bundle/SCHEMA.md" diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 42856045..26ccd942 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -176,7 +176,8 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} moved = _graphline.base_moved_line(cwd, st) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) - since = f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) else f"the graph's commit {(j.get('built_at') or '')[:10]}" + since = (f"{head_at}: the commits that came in are not counted" if (AGAINST or j.get('base_moved')) + else "the files the graph was indexed from" if j.get('against_index') else f"the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against {since}) —") st['reported'] = list(seen | {key(d) for d in new}) @@ -189,7 +190,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] head_at = ' '.join(x for x in ('HEAD', (j.get('base_moved') or {}).get('new', '')[:10]) if x) since = (f"against {head_at} (the commits that came in are not counted)" if (AGAINST or j.get('base_moved')) - else f"since the graph's commit {(j.get('built_at') or '')[:10]}") + else "since the graph was indexed" if j.get('against_index') else f"since the graph's commit {(j.get('built_at') or '')[:10]}") if new: lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree {since} and were not reported yet —") st['reported'] = list(seen | {key(d) for d in new}) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 0b49ea84..036e7f4a 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -207,7 +207,7 @@ def impact(d): except Exception: return d, {} with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) - base = (ch.get('built_at') or '')[:10] + base = '' if ch.get('against_index') else (ch.get('built_at') or '')[:10] # no commit recorded: nothing to name if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base and before is None else '') + " —") for d, j in results: head = f" {d.get('label') or d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') @@ -263,6 +263,10 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' try: against = open(os.path.join(cwd, '.axiomcode', 'out', 'indexed-tree')).read().strip() or built except OSError: against = built if against != 'nogit' and subprocess.run(['git', 'diff', '--quiet', against, '--', rel], cwd=cwd, capture_output=True).returncode == 1: stale = f" — this file changed since the graph was built at {built[:10]}: lines are the graph's, not the file's" + elif against == 'nogit': # no commit recorded: the hash the index read it with + import ax_fresh + rec = ((ax_fresh.load_table(cwd) or {}).get('files') or {}).get(rel) + if rec and ax_fresh.digest(os.path.join(cwd, rel)) != rec[2]: stale = " — this file changed since the graph was indexed: lines are the graph's, not the file's" except Exception: pass if rows: st = load_state(); ctx = set(context_ids()) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 415c183d..cce1ea3c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -504,15 +504,34 @@ def _vnum(v): except (TypeError, ValueError): return None def _newer(old, impact, now_e): - """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id)""" + """newer_build's comparison: the table's built_by against this axiomcode's IMPACT_VERSION and engine (engine_id). + AHEAD ON EITHER IS NEWER. A lower IMPACT_VERSION returned '' before the engines were compared, so a graph a 0.1.8 + engine built, read by these scripts with AXIOMCODE_ENGINE naming a 0.1.3 checkout, was called "built by an older + axiomcode (0.1.8 -> 0.1.3)" and rebuilt with the older engine""" oi, ni = _vnum(old.get('impact')), _vnum(impact) - if oi and ni and oi != ni: - return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" if oi > ni else '' + if oi and ni and oi > ni: + return f"graph built by a newer axiomcode (IMPACT_VERSION {old.get('impact')}, this one has {impact})" ov, nv = old.get('engine_version'), (now_e[0] if now_e else None) if _vnum(ov) and _vnum(nv) and _vnum(ov) > _vnum(nv): return f"graph built by a newer axiomcode (engine {ov}, this one is {nv})" return '' +def _answering(): + """the engine checkout these scripts sit in, the one `axiomcode --version` reports; '' when they sit in none""" + w = H + while os.path.dirname(w) != w and not os.path.isfile(os.path.join(w, 'bin', 'axiomcode')): w = os.path.dirname(w) + return w if engine_ok(w) else '' + +def _whose(eng): + """WHICH ENGINE "THIS ONE" IS, when it is not the one answering: AXIOMCODE_ENGINE names another checkout, and a + rebuild here uses that one. Unsaid, "0.1.8 -> 0.1.3" read as a version going backwards under `axiomcode --version` + printing 0.1.8. '' when the engine compared is the checkout answering""" + a = _answering() + if not eng or not a or os.environ.get('AXIOMCODE_ENGINE') != eng or os.path.realpath(a) == os.path.realpath(eng): return '' + try: v = json.load(open(os.path.join(a, 'package.json'))).get('version', '?') + except (OSError, ValueError): v = '?' + return f"; compared with AXIOMCODE_ENGINE={eng}, which a rebuild here uses, not the axiomcode answering ({v} at {a})" + def built_by_state(repo, t=None): """(older, newer): what engine_change and newer_build return, found with one look at the engine. NEVER A DOWNGRADE comes first: a graph a newer axiomcode built is newer whatever else differs, and only a graph that is not is judged @@ -532,7 +551,7 @@ def built_by_state(repo, t=None): langs = old.get('engine_langs') now_e = engine_id(eng, langs) if eng else None newer = _newer(old, impact, now_e) - if newer: return '', newer + if newer: return '', newer + _whose(eng) diff = [] if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: h = _seen_hash(repo, now_e) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index e393ae73..60068b7b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -219,6 +219,15 @@ set_base(){ # the commit the baseline follows, written LAST: `changed` waits while it differs from HEAD, so it must not say "moved" # before the baseline's graph exists (keep_base_graph), or `changed` reads a baseline with no graph for it commit_base(){ [ -n "$GD" ] && echo "$HEAD_SHA" > "$OUT/base-commit"; return 0; } +# THE BASELINE WITHOUT GIT (a mirror synced without .git, an export): no tree to record, so `changed` reads an edit as a +# file whose hash differs from the table an explicit index wrote (axiomcode-changed, since_index). Kept as base-files.json, +# since every build, a background refresh too, rewrites files.json: read from it, an edit left `changed` a minute after it +# was made. An explicit index moves it; a refresh keeps it; a build with git has a tree and drops it. +set_base_files(){ + if [ -n "$GD" ]; then rm -f "$OUT/base-files.json" + elif [ -z "$KEEP_BASE" ] || [ ! -f "$OUT/base-files.json" ]; then [ -f "$OUT/files.json" ] && cp "$OUT/files.json" "$OUT/base-files.json"; fi + return 0 +} # THE BASELINE GRAPH. `changed`, `test-impact` and the change reports ask about an EDIT: which declarations it touched and # who depended on them before it, a removed method's callers included. After a background refresh the graph no longer # has the removed method, and its spans are in the new text. So the graph that describes the baseline is kept, moved @@ -287,6 +296,7 @@ if [ -z "${AXIOMCODE_REINDEX:-}" ] && [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/grap fi if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi + set_base_files has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 @@ -457,6 +467,7 @@ publish_main(){ if [ -n "$OTHER_LANGS" ]; then S="${SOLVE#*,}"; for l in ${S//,/ }; do echo "$l $MODE"; done > "$PENDING"; fi # before the pointer: the first query may follow at once point "$DB" || { restore; exit 1; }; rm -f "$OUT/.live.sqlite" if [ -f "$OUT/.files.json.new" ]; then mv "$OUT/.files.json.new" "$OUT/files.json"; else rm -f "$OUT/files.json"; fi + set_base_files # the graph now matches the files: record how long that took, and whether it included compiling the engine's rules (a # one-time cost a refresh does not pay, so such a duration predicts nothing). With other languages still to come this # is the time to the MAIN graph, which is what a query waits for. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 838bc03c..03c0b597 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -42,6 +42,14 @@ CODE_EXT = r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$' def is_git(repo): return sh('git', 'rev-parse', '--git-dir', cwd=repo) is not None +def base_table(repo): + """the file table a graph with no commit is diffed against: the one the last explicit index kept (base-files.json, + which a background refresh leaves as it is), else the current one""" + try: return json.load(open(os.path.join(repo, '.axiomcode', 'out', 'base-files.json'))) + except (OSError, ValueError): + sys.path.insert(0, HERE); import ax_fresh + return ax_fresh.load_table(repo) + def resolve_range(repo, rng): """(old rev, new rev, note) for `--range`. `a..b` means what an agent asking about ITS commits means: what b added since it forked from a, so the old side is `git merge-base a b`, as `a...b` already is. Diffing the two TIPS, as this did, @@ -202,6 +210,20 @@ class Changed: b = os.path.join(self.repo, '.axiomcode', 'out', 'base-tree') b = open(b).read().strip() if os.path.exists(b) else '' self.base_tree = b if b and sh('git', 'cat-file', '-e', b, cwd=self.repo) is not None else self.indexed_tree + # NO COMMIT RECORDED: a graph built where there was no git (a mirror synced without .git, an export) stamps 'nogit' + # and writes no tree. Read against HEAD, every uncommitted edit the index had READ came back as changed right after + # it (a fresh index of a dirty mirror: 208 declarations), and in a copy without git nothing could be answered. The + # file table the build wrote holds each file's hash as the parser read it: a file whose bytes still match is + # unchanged against the graph, whatever git says; one that differs, is new or is gone is the edit (since_index). + self.since_index = None + if (not self.built_at or self.built_at == 'nogit') and not self.base_tree: + sys.path.insert(0, HERE); import ax_fresh + t = base_table(self.repo) + if t and t.get('files'): + c = ax_fresh.changes(self.repo, t) + if c is not None: + self.since_index = set(c[0]) | set(c[1]) | set(c[2]); self.indexed_files = set(t['files']) + self.index_built = float(t.get('built') or 0) # THE BASE MOVED UNDER THE BASELINE: a rebase, a pull, a checkout, a reset or a commit since it was set, and the # refresher has not caught up (it builds HEAD's text first, which takes minutes on a dirty tree, and never runs # with refresh off). Read against the old baseline, every change the new commits brought in (upstream's) came @@ -419,6 +441,13 @@ class Changed: # dropped here, an edit to them answered "no change" and test-impact "no test can be selected", which reads as # "nothing to test". They are kept apart, named, and test-impact looks for the tests that load them. lines = [l for l in (out or '').split('\n') if l.strip()] + if self.since_index is not None and mode == 'worktree': + # no commit to diff against: the files the table says changed since the index, and a file the table does not + # watch (a fixture, a schema) that git lists and that was written after the index + def later(l): + try: return os.path.getmtime(os.path.join(self.repo, l)) > self.index_built + except OSError: return False + lines = sorted(self.since_index | {l for l in lines if l not in self.indexed_files and not self.is_code(l) and later(l)}) self.outside = [l for l in lines if not self.is_code(l)] return [l for l in lines if self.is_code(l)] def is_code(self, rel): @@ -1411,7 +1440,9 @@ def main(argv): # function as added at line 1 and docstring words as methods. Without named files there is no answer to give: say so # and say what to pass. With named files, each counts whole (below). git = is_git(rrepo) - if not git and not (old_f or new_f) and not a: + sys.path.insert(0, HERE); import ax_fresh + # ...unless the graph's file table holds what the index read: an edit is a file that differs from it (Changed.since_index) + if not git and not (old_f or new_f) and not a and mode == 'worktree' and not (base_table(rrepo) or {}).get('files'): msg = NO_GIT.format(repo=rrepo) if as_json: print(json.dumps({'built_at': None, 'changed': [], 'notes': [], 'refused': msg, 'no_git': True}, indent=1)) else: print(msg) @@ -1422,7 +1453,6 @@ def main(argv): # A caller that already chose it (test-impact, the change hook) passes it in AXIOMCODE_GRAPH; either way it is the # baseline's graph, and its spans are not mapped again. baseline = None; fanout_empty = False - sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): bg = ax_fresh.baseline_graph(rrepo); given = os.environ.get('AXIOMCODE_GRAPH') # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a @@ -1490,9 +1520,13 @@ def main(argv): for rel in files: if a and not git: # a named file, no git: every declaration in it results += C.whole_file(rel, 'no git base to diff against'); continue + since_index = C.since_index is not None and mode == 'worktree' and rel in C.since_index + if since_index and not git: # changed since the index, no text of it before + results += C.whole_file(rel, 'changed since the index, and no git to diff its declarations against'); continue old, new = C.texts(rel, mode, rng) if old == new: if a and whole: results += C.whole_file(rel, f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + elif since_index: results += C.whole_file(rel, 'changed since the index, back to the text HEAD has: which declarations changed cannot be told') continue if flipped and not old.strip() and new.strip(): # a file the range adds: one line, not its decls reversed results += C.new_file(rel, new, C.decl_spans(rel, mode), rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython', new))) @@ -1521,14 +1555,16 @@ def main(argv): # A BASELINE THAT HOLDS EDITS (an explicit index of an edited tree, or a rebuild run as one) hides them: said, with how # to count them, rather than a bare "no change" over a real edit. Only when the question is the working tree as a whole base_note = C.baseline_note() if mode == 'worktree' and not a and not (old_f or new_f) else '' - base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else + by_index = C.since_index is not None and mode == 'worktree' and not (old_f or new_f) + base_desc = ("working tree against the files the graph was indexed from (no commit recorded)" if by_index else + f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None mv = C.moved if mode in ('worktree', 'head') and not (old_f or new_f) else None moved_json = dict(mv, note=moved_note(mv)) if mv else None if as_json: - print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), + print(json.dumps({'built_at': C.built_at, 'against_index': by_index, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, 'baseline_note': base_note, 'suggest_range': suggest, 'suggest_note': sugg_line, 'base_moved': moved_json}, indent=1)) return 3 if fanout_empty else 0 @@ -1540,6 +1576,8 @@ def main(argv): if sugg_line: print(f"next: {sugg_line}") return 3 if fanout_empty else 0 against = (f" — against HEAD {mv['new'][:10]} (the base moved: see the note)" if mv else + " — against the files the graph was indexed from (it recorded no commit): a file unchanged since the index is " + "not an edit; a changed one is read " + ("against HEAD" if git else "whole") if by_index else f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fa746581..e8186697 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -117,7 +117,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # it obtained itself, so an added proxied annotation does not apply") — the differential test caught that immediately. # `ref` is not here either: the alongside layer reads it to tell a sibling that touches the same field from one that does # not, for EVERY target kind. What is left is the layer only a field or type target can reach. -_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'sigtype', 'persist_field', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'field_holder', 'accessor', 'faccess', 'gen_table', 'base_name', 'field_type', 'reexport', 'reexport_from', 'switch_over'} _CONFIG = {'config', 'config_key_known', 'config_site'} # `reexport` is NOT out of a method's reach: rules 396 and 398 both start at target(q,"method",m,_) — the barrel @@ -1340,10 +1340,13 @@ class Impact: mod_of = {} for i, sy in g.sym.items(): if sy['kind'] == 'module' and sy.get('file'): mod_of.setdefault(sy['file'], i) - modules_used = set() + modules_used = set(); holders = []; key_names = set() for rid, f in self.fields.items(): # the field's own registry hash when the index has it, so a decoration ON the field joins (#750) fid = f['id'] or f"f:{rid}"; t = owner_tid(f['owner'] or '', f.get('file')) + # a key of a module-level object (`TOPICS.CREATED`) is owned by an OBJECT, not a type: its module owns it, and + # the object's name is what a read of it is written behind (`TOPICS.CREATED`, `nested.depth`) + if not t and f['owner']: holders.append((fid, f['owner'].rsplit('.', 1)[-1])); key_names.add(f['name']) if not t: t = mod_of.get(f.get('file') or '') if not t: continue if t in mod_of.values(): modules_used.add(t) @@ -1365,7 +1368,7 @@ class Impact: (fid, 'init_' + f['name'], 'write'), (fid, 'get_' + cap, 'read'), (fid, 'set_' + cap, 'write'), (fid, 'init_' + cap, 'write')] if f['name'].startswith('_') and len(f['name']) > 1: acc.append((fid, f['name'].lstrip('_'), 'read')) - W('member', members); W('owner', owners); W('field', fields); W('accessor', acc) + W('member', members); W('owner', owners); W('field', fields); W('accessor', acc); W('field_holder', sorted(set(holders))) # the accesses the ENGINE resolved, joined on the same id the `field` fact uses: symbols.id for a # field IS the bundle's fields.id. #1071 — the relation shipped populated and no rule read it, so a # resolved access was answered as a name match, with its read/write direction discarded. @@ -1493,13 +1496,15 @@ class Impact: for r in g.q("""SELECT DISTINCT s.caller_id, x.c1 t, s.file_path, s.start_line FROM ext_ctor_implicit_type x JOIN call_sites s ON s.id = x.c0""")] if g.has('ext_ctor_implicit_type') else []) refs = []; quals = [] + # an object's key names: TypeScript records the member of `TOPICS.CREATED` as a bare name, and the qualifier + # written in front of it is what tells that object's key from another's if g.has('refs'): for r in g.q("SELECT name, file, line, kind, entity_kind FROM refs WHERE line > 0"): c = self.at(r['file'], r['line']) if not c: continue rk = 'qualified' if r['kind'] in QUALIFIED_KINDS else 'bare'; ek = 'CLASS_LITERAL' if r['kind'] == 'CLASS_LITERAL' else (r['entity_kind'] or '') refs.append((c, r['name'], rk, ek, r['file'], r['line'])) - if rk == 'qualified': + if rk == 'qualified' or r['name'] in key_names: L = self.code(r['file']); text = L[r['line'] - 1] if r['line'] <= len(L) else '' for qn in re.findall(rf'([A-Za-z_$][\w$]*)\s*\.\s*{re.escape(r["name"])}\b', text): quals.append((r['file'], r['line'], r['name'], qn)) W('ref', refs); W('qualifier', sorted(set(quals))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 830ba9a5..10a7bfec 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -20,9 +20,10 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) -INDEX_VERSION = '6' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; +INDEX_VERSION = '7' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; - # v6: TypeScript class-property arrows named after their field); the query + # v6: TypeScript class-property arrows named after their field; + # v7: data keys of module-level const objects declared, Object.freeze seen through); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -114,7 +115,8 @@ A = { # `obj.x = function …`, `{ all: (p) => … }`: named for the property, as JavaScript's are (#1585). The rows are # read through `ts_member_rows`, which gives them JavaScript's expression shape memberNames=dict(file='all-typescript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-typescript-variables.csv', shape='typescript'), + vars='all-typescript-variables.csv', shape='typescript', + consts=lambda r: r.get('scopeKind') == 'MODULE_SCOPE' and r.get('isConst') == 'true'), skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -150,7 +152,9 @@ A = { # `exports.getUser = function …`, `obj.x = () => …`, `{ all: page => … }`: the function is named for the property it # is the value of, the way the language's own name inference does it, and is owned by the object's variable memberNames=dict(file='all-javascript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-javascript-variables.csv'), + vars='all-javascript-variables.csv', + # the module-level consts whose object literal's data keys are declarations (`TOPICS.CREATED`) + consts=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime', '').startswith('CONST') and not r.get('importLinkHash')), # `static [Symbol.hasInstance](x) {…}`: the parser leaves the name empty and links the key expression; the member is # named `[Symbol.hasInstance]`, as written, instead of displaying as `Tagged.` with nothing to ask for computedNames=dict(file='all-javascript-methods.csv', id='jsMethodUniqueHash', key='computedNameExpressionLinkHash', @@ -322,8 +326,12 @@ def ts_member_rows(file): p, role, pos = up(r) o = dict(jsExpressionUniqueHash=i, parentExpressionLinkHash=p['tsExpressionUniqueHash'] if p else '', edgeRole=TS_ROLES.get(role, role), childIndex=pos, operatorString=r.get('operatorString', ''), - expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT'}.get(kind, kind), - introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', '')) + expressionKind={'ARROW_FUNCTION': 'FUNCTION_EXPRESSION', 'ASSIGNMENT_EXPRESSION': 'ASSIGNMENT', 'CALL_EXPRESSION': 'CALL'}.get(kind, kind), + introducesDeclarationLinkHash=r.get('anonymousDeclarationHash', ''), + ownerModuleLinkHash=r.get('tsModuleLinkHash', ''), startLine=r.get('startLine', '')) + if kind == 'CALL_EXPRESSION': # the callee as written, the way JavaScript's `text` starts: `Object.freeze(` + callee = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'METHOD_NAME'), None) + o['text'] = (dotted(callee) or '') + '(' if callee else '' if role == 'OBJECT_PROPERTY_KEY' and (kind == 'IDENTIFIER_REFERENCE' or (kind == 'LITERAL' and r.get('literalType') in ('STRING', 'NUMBER'))): o['name'] = r.get('literalValue', '') @@ -334,19 +342,35 @@ def ts_member_rows(file): out[i] = o return out.values(), {i: inner(i)['tsExpressionUniqueHash'] for i, r in src.items() if r.get('kind') in TS_WRAPPERS and inner(i)} bound_owner = {}; cls_named = {} +data_keys = [] # (key, owner key chain, the const's variable row, key row): see below if A.get('memberNames'): mn = A['memberNames']; ex = {}; kids = collections.defaultdict(list) ts_shape = mn.get('shape') == 'typescript' mrows, unwrap = ts_member_rows(mn['file']) if ts_shape else (rows(mn['file']), {}) for r in mrows: ex[r[mn['id']]] = r; kids[r.get('parentExpressionLinkHash', '')].append(r) - lit_var = {unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash']): r['name'] - for r in rows(mn['vars']) if r.get('initializerExpressionLinkHash') and r.get('name')} DOTTED = re.compile(r'^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$') def key_of(parent, role, idx): for k in kids.get(parent, ()): if k.get('edgeRole') == role and (idx is None or k.get('childIndex') == idx): return k + # `const T = Object.freeze({…})` holds the literal it is handed, as `const T = {…}` does: the variable's initializer + # is the CALL, and the literal it names is the call's argument. Without this the literal had no variable, so its + # function-valued keys lost their owner and its data keys had nothing to be declared under + FREEZE = re.compile(r'^Object\s*\.\s*(freeze|seal|preventExtensions)\s*\(') + def held_literal(i): + r = ex.get(i) + if r is not None and r.get('expressionKind') == 'CALL' and FREEZE.match(r.get('text') or ''): + a = next((k for k in kids.get(i, ()) if k.get('edgeRole') == 'ARGUMENT'), None) + a = ex.get(unwrap.get(a[mn['id']], a[mn['id']])) if a else None + if a is not None and a.get('expressionKind') == 'OBJECT_LITERAL': return a[mn['id']] + return i + lit_var = {}; const_lit = {} # literal -> variable name; literal -> variable row, module-level consts only + for r in rows(mn['vars']): + if not (r.get('initializerExpressionLinkHash') and r.get('name')): continue + i = held_literal(unwrap.get(r['initializerExpressionLinkHash'], r['initializerExpressionLinkHash'])) + lit_var[i] = r['name'] + if mn.get('consts') and mn['consts'](r): const_lit[i] = r def literal_owner(lit, depth=0): if lit.get(mn['id']) in lit_var: return lit_var[lit[mn['id']]] p = ex.get(lit.get('parentExpressionLinkHash', '')) @@ -389,6 +413,28 @@ if A.get('memberNames'): if name: bound[m_] = name if owner: bound_owner[m_] = owner + # A DATA KEY OF A MODULE-LEVEL CONST OBJECT is a declaration: `export const TOPICS = Object.freeze({ CREATED: 'a.b' })` + # declares TOPICS.CREATED, and `bus.publish(TOPICS.CREATED)` reads it. Only a function-valued key was a declaration (a + # method, above), so `impact TOPICS.CREATED` answered "nothing named" and its readers were word matches. A nested + # literal's keys follow the key chain (`LIMITS.nested.depth`). Not declared: a function (already the method), a bare + # name or shorthand (`{ create }` is another declaration, not a value of its own), a computed key, and any literal a + # function body builds or a `let` holds -- those are values, not the module's named constants + def const_root(lit, depth=0): + if lit[mn['id']] in const_lit: return const_lit[lit[mn['id']]] + p = ex.get(lit.get('parentExpressionLinkHash', '')) + if depth > 8 or not p or lit.get('edgeRole') != 'PROPERTY_VALUE' or p.get('expressionKind') != 'OBJECT_LITERAL': return None + return const_root(p, depth + 1) + NOT_DATA = {'FUNCTION_EXPRESSION', 'CLASS_EXPRESSION', 'IDENTIFIER', 'IDENTIFIER_REFERENCE'} + for k in list(ex.values()) if const_lit else (): + if k.get('edgeRole') != 'PROPERTY_KEY' or k.get('isComputedName') == 'true' or not k.get('name'): continue + p = ex.get(k.get('parentExpressionLinkHash', '')) + if not p or p.get('expressionKind') != 'OBJECT_LITERAL': continue + v = key_of(p[mn['id']], 'PROPERTY_VALUE', k.get('childIndex')) + v = ex.get(unwrap.get(v[mn['id']], v[mn['id']])) if v else None + if v is None or v.get('expressionKind') in NOT_DATA: continue + root = const_root(p); owner = literal_owner(p) if root else None + if not owner: continue + data_keys.append((k['name'], owner, root, k)) # ── types: display names with nesting recovered by line containment ─────────────────────────────── types = {r['id']: dict(r) for r in c.execute("SELECT id, name, qualified_name, category, file_path, start_line, end_line, provenance FROM types")} byfile = {} @@ -570,6 +616,14 @@ for d in A['decls']: qn = ((ot['qualified_name'] + '.' + name) if ot and ot.get('qualified_name') else None) or r.get('qualifiedName') or \ (((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name) sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) +# the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key +# chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes +for name, od, v, k in data_keys: + fp = rel(modules.get(k.get('ownerModuleLinkHash', ''), '')) or rel(v.get('filePath', '')) + ln = int(k.get('startLine') or 0) + vq = v.get('qualifiedName') or v.get('potentialQualifiedName') + qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" + sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 567785c3..bdf2971a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -82,6 +82,8 @@ .decl reexport(c:symbol, n:symbol, f:symbol, l:number) .input reexport .decl reexport_from(f:symbol, l:number, src:symbol) .input reexport_from .decl field(fl:symbol, t:symbol, n:symbol, f:symbol, l:number) .input field +// field_holder(fl, h): fl is a key of an OBJECT named h, not a member of a type (`TOPICS` of `TOPICS.CREATED`) +.decl field_holder(fl:symbol, h:symbol) .input field_holder .decl accessor(fl:symbol, n:symbol, rw:symbol) .input accessor .decl faccess(c:symbol, fl:symbol, acc:symbol, tier:symbol, f:symbol, l:number) .input faccess .decl gen_table(d:symbol, what:symbol) .input gen_table @@ -468,7 +470,10 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), - (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"), (ek != "OBJECT_PROPERTY_KEY" ; !key_decl_at(n, f, l)). +// the key that DECLARES another object's same-named key (`QUEUES = { CREATED: … }`) is that declaration, not a use of this one +.decl key_decl_at(n:symbol, f:symbol, l:number) +key_decl_at(n, f, l) :- field_holder(fl, _), field(fl, _, n, f, l). // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) @@ -555,7 +560,7 @@ direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, rol direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and @@ -567,13 +572,25 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !holds_key(qn, n), !field_holder(fl, _), !fa_known(q, c), !const_routed(q, c, f, l). +// A KEY OF A MODULE-LEVEL OBJECT, read behind the object's own name: `TOPICS.CREATED` reads TOPICS's key wherever it is +// written, as `Order.TAX` reads Order's constant. The object is one value, read by its own name, so behind any OTHER +// name (`QUEUES.CREATED`, `upstreams.workspaces`) it is another object's member, and the by-name rules above leave it +// out (field_holder); a field of a TYPE read behind an object that holds a key of that name is left out too (holds_key) +.decl holds_key(h:symbol, n:symbol) +holds_key(h, n) :- field_holder(fl, h), field(fl, _, n, _, _). +// TypeScript records the member of `TOPICS.CREATED` as a BARE name, so the qualifier decides for a bare reference too. +// A bare name with no such qualifier is never an object's key -- a key is only ever read behind its object -- so the +// bare rules below leave object keys out: `workspaces.close()` is a variable, not OFFSETS.workspaces +direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, _, f, l), target(q, "field", fl, _), field(fl, t, n, _, _), field_holder(fl, h), + !scope(t, c), qualifier(f, l, n, h), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l), !field_holder(fl, _). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f), + !field_holder(fl, _). // a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in // one module and a wrapped handler of the same name in another are two declarations, and each answer carried the // other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` diff --git a/tests/cases/javascript/const-object-data-keys/case.json b/tests/cases/javascript/const-object-data-keys/case.json new file mode 100644 index 00000000..d970ff56 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/case.json @@ -0,0 +1,55 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object literal, through Object.freeze, is a declaration: its readers are the functions that read TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.js:4"], + "avoid": ["nothing named", "remove", "enqueue"] + }, + { + "why": "a same-named key of ANOTHER const object is another declaration: QUEUES.CREATED is read by enqueue only, and TOPICS.CREATED's reader is not its reader", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.js:12"], + "avoid": ["nothing named", "create service.js"] + }, + { + "why": "CONTROL: a key read behind another name (`settings.page`, `counts.page`) is another object's member, not LIMITS.page", + "run": ["impact", "LIMITS.page"], + "want": ["change: field LIMITS.page [field]", "pageSize service.js:16"], + "avoid": ["mirror", "local"] + }, + { + "why": "a plain const object literal, and a nested literal's key, are declared under their key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "pageSize service.js:16"], + "avoid": ["nothing named"] + }, + { + "why": "a CommonJS const object's key is declared the same way, and a member read of it is its reader; a captured local of the same name (`api.close()`) is not", + "run": ["impact", "OFFSETS.api"], + "want": ["change: field OFFSETS.api [field]", "apiPort ports.cjs:12"], + "avoid": ["nothing named", "closeLater", ""] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.js:29"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.js:20"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing: its key is not a module-level declaration", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/javascript/const-object-data-keys/ports.cjs b/tests/cases/javascript/const-object-data-keys/ports.cjs new file mode 100644 index 00000000..bfcb10b4 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/ports.cjs @@ -0,0 +1,17 @@ +'use strict'; +const OFFSETS = Object.freeze({ + web: 0, + api: 1, +}); + +function portFor(name) { + return 1000 + OFFSETS[name]; +} + +function apiPort() { + return 1000 + OFFSETS.api; +} + +exports.OFFSETS = OFFSETS; +exports.portFor = portFor; +exports.apiPort = apiPort; diff --git a/tests/cases/javascript/const-object-data-keys/service.js b/tests/cases/javascript/const-object-data-keys/service.js new file mode 100644 index 00000000..d24764b5 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/service.js @@ -0,0 +1,39 @@ +import { TOPICS, LIMITS, QUEUES, HANDLERS, helpers } from './topics.js'; + +export function create(bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function remove(bus) { + bus.publish(TOPICS.DELETED, {}); +} + +export function enqueue(q) { + q.send(QUEUES.CREATED); +} + +export function pageSize() { + return LIMITS.page + LIMITS.nested.depth; +} + +export function loud(s) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} + +export function dispatch(job) { + return HANDLERS.run(job); +} + +export function closeLater(open) { + const api = open(); + return () => api.close(); +} + +export function mirror(settings) { + return settings.page; +} diff --git a/tests/cases/javascript/const-object-data-keys/topics.js b/tests/cases/javascript/const-object-data-keys/topics.js new file mode 100644 index 00000000..1fd78023 --- /dev/null +++ b/tests/cases/javascript/const-object-data-keys/topics.js @@ -0,0 +1,21 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const LIMITS = { + page: 50, + nested: { depth: 3 }, +}; + +export const QUEUES = Object.freeze({ + CREATED: 'queue.created', +}); + +export const helpers = { + shout: (s) => s.toUpperCase(), +}; + +export const HANDLERS = Object.freeze({ + run: (job) => job.id, +}); diff --git a/tests/cases/typescript/const-object-data-keys/case.json b/tests/cases/typescript/const-object-data-keys/case.json new file mode 100644 index 00000000..e8893670 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/case.json @@ -0,0 +1,43 @@ +{ + "lang": "typescript", + "src": ".", + "checks": [ + { + "why": "a data-valued key of an exported const object, through Object.freeze, is a declaration read by the function that reads TOPICS.CREATED", + "run": ["impact", "TOPICS.CREATED"], + "want": ["change: field TOPICS.CREATED [field]", "create service.ts:6"], + "avoid": ["nothing named", "enqueue"] + }, + { + "why": "through `as const` too, and a same-named key of another object is another declaration", + "run": ["impact", "QUEUES.CREATED"], + "want": ["change: field QUEUES.CREATED [field]", "enqueue service.ts:10"], + "avoid": ["nothing named", "create service.ts"] + }, + { + "why": "a nested literal's key is declared under its key chain", + "run": ["impact", "LIMITS.nested.depth"], + "want": ["change: field LIMITS.nested.depth [field]", "nestedDepth service.ts:14"], + "avoid": ["nothing named"] + }, + { + "why": "a function-valued key inside Object.freeze is owned by the variable, as it is in a bare literal", + "run": ["impact", "HANDLERS.run"], + "want": ["change: HANDLERS.run [method]", "dispatch service.ts:18"], + "avoid": ["nothing named", "[field]"] + }, + { + "why": "CONTROL: a function-valued key stays the method it was, not a second field", + "run": ["impact", "helpers.shout"], + "want": ["change: helpers.shout [method]", "loud service.ts:22"], + "avoid": ["[field]", "more than one kind"] + }, + { + "why": "CONTROL: an object literal inside a function body declares nothing", + "run": ["impact", "counts.page"], + "expect_error": true, + "want": ["the graph has no declaration for 'counts.page'"], + "avoid": ["change: field counts.page"] + } + ] +} diff --git a/tests/cases/typescript/const-object-data-keys/service.ts b/tests/cases/typescript/const-object-data-keys/service.ts new file mode 100644 index 00000000..38525808 --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/service.ts @@ -0,0 +1,28 @@ +import { TOPICS, QUEUES, LIMITS, HANDLERS, helpers } from './topics'; + +interface Bus { publish(topic: string, body: object): void; } + +export function create(bus: Bus) { + bus.publish(TOPICS.CREATED, {}); +} + +export function enqueue(bus: Bus) { + bus.publish(QUEUES.CREATED, {}); +} + +export function nestedDepth(): number { + return LIMITS.nested.depth; +} + +export function dispatch(id: string) { + return HANDLERS.run(id); +} + +export function loud(s: string) { + return helpers.shout(s); +} + +export function local() { + const counts = { page: 1 }; + return counts.page; +} diff --git a/tests/cases/typescript/const-object-data-keys/topics.ts b/tests/cases/typescript/const-object-data-keys/topics.ts new file mode 100644 index 00000000..1ba99e1d --- /dev/null +++ b/tests/cases/typescript/const-object-data-keys/topics.ts @@ -0,0 +1,20 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); + +export const QUEUES = { + CREATED: 'queue.created', +} as const; + +export const LIMITS = { + nested: { depth: 3 }, +}; + +export const HANDLERS = Object.freeze({ + run: (id: string) => id.length, +}); + +export const helpers = { + shout: (s: string) => s.toUpperCase(), +}; diff --git a/tests/cases/typescript/keyed-callback-registry/case.json b/tests/cases/typescript/keyed-callback-registry/case.json new file mode 100644 index 00000000..56538eae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/case.json @@ -0,0 +1,46 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a callback stored in a Map under a key is run by the publish on the same key: the publisher reaches it through the registry, the key read through a const-object member", + "run": ["path", "create", "onCreated"], + "want": ["1 of 1 target(s) reached", "onCreated"], + "avoid": ["no chain of resolved calls"]}, + {"why": "a handler registered under the wildcard the dispatch also looks up runs for every key", + "run": ["path", "create", "onAnything"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a handler registered under another key is not run by that publish", + "run": ["path", "create", "onRemoved"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a handler table handed to a method that subscribes each entry under its own key: the entry under the published key is reached", + "run": ["path", "create", "Indexer.indexed"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the table entry under another key is not", + "run": ["path", "create", "Indexer.dropped"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "a key only known at run time may be any key: every handler of that registry is reached", + "run": ["path", "relay", "onRemoved"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: a method that reads the registry by key but never calls what it finds reaches no handler", + "run": ["path", "size", "onCreated"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "the terse form: the set created inline in the store, the lookup iterated directly and through forEach on a literal wildcard", + "run": ["path", "go", "onA"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "and the wildcard registration of the terse form", + "run": ["path", "go", "onAll"], + "want": ["1 of 1 target(s) reached"], + "avoid": ["no chain of resolved calls"]}, + {"why": "CONTROL: the terse form's other key", + "run": ["path", "go", "onB"], + "want": ["no chain of resolved calls"], + "expect_error": true}, + {"why": "test-impact crosses the registry: the test that publishes the key is selected for the handler", + "run": ["impact", "onCreated", "--tests-only"], + "want": ["app.test.ts"]} + ]} diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.test.ts b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts new file mode 100644 index 00000000..f05f678a --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.test.ts @@ -0,0 +1,10 @@ +import { create, remove } from './app'; + +describe('bus', () => { + it('creates', () => { + create(); + }); + it('removes', () => { + remove(); + }); +}); diff --git a/tests/cases/typescript/keyed-callback-registry/src/app.ts b/tests/cases/typescript/keyed-callback-registry/src/app.ts new file mode 100644 index 00000000..b4a0adae --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/app.ts @@ -0,0 +1,68 @@ +import { ANY, Bus, MiniBus } from './bus'; +import { TOPIC } from './topics'; + +export const bus = new Bus(); + +export function onCreated(p: unknown): void { + console.log('created', p); +} +export function onRemoved(p: unknown): void { + console.log('removed', p); +} +export function onAnything(p: unknown): void { + console.log('any', p); +} + +export class Indexer { + constructor(private readonly b: Bus) {} + + start(): void { + this.b.subscribeAll({ + [TOPIC.created]: (p) => this.indexed(p), + [TOPIC.removed]: (p) => this.dropped(p), + }); + } + + indexed(p: unknown): void { + console.log('indexed', p); + } + dropped(p: unknown): void { + console.log('dropped', p); + } +} + +bus.subscribe(TOPIC.created, onCreated); +bus.subscribe('item.removed', onRemoved); +bus.subscribe(ANY, onAnything); +new Indexer(bus).start(); + +export function create(): void { + bus.publish(TOPIC.created, { id: 1 }); +} +export function remove(): void { + bus.publish('item.removed', { id: 1 }); +} +// the name is only known at run time: every handler of the registry may run +export function relay(evt: { type: string }): void { + bus.publish(evt.type, evt); +} +export function size(): number { + return bus.count(TOPIC.created); +} + +const mini = new MiniBus(); +export function onA(): void { + console.log('a'); +} +export function onAll(): void { + console.log('all'); +} +export function onB(): void { + console.log('b'); +} +mini.on('a', onA); +mini.on('*', onAll); +mini.on('b', onB); +export function go(): void { + mini.fire('a', 1); +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/bus.ts b/tests/cases/typescript/keyed-callback-registry/src/bus.ts new file mode 100644 index 00000000..ee7fad81 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/bus.ts @@ -0,0 +1,59 @@ +export const ANY = '*'; + +export type Handler = (payload: unknown) => void; + +// A registry of callbacks keyed by name: a Map from the name to a Set of handlers. +export class Bus { + private readonly handlers = new Map>(); + + subscribe(name: typeof ANY, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void; + subscribe(name: string, fn: Handler): () => void { + let set = this.handlers.get(name); + if (!set) { + set = new Set(); + this.handlers.set(name, set); + } + set.add(fn); + return () => { + set.delete(fn); + }; + } + + // every entry of the table is subscribed under its own key + subscribeAll(table: Record): void { + for (const name of Object.keys(table)) { + const fn = table[name]; + if (fn) this.subscribe(name, fn); + } + } + + // reads the registry by key and never calls what it finds + count(name: string): number { + return this.handlers.get(name)?.size ?? 0; + } + + publish(name: string, payload: unknown): void { + const run = () => this.dispatch(name, payload); + run(); + } + + private dispatch(name: string, payload: unknown): void { + const targets: Handler[] = [...(this.handlers.get(name) ?? []), ...(this.handlers.get(ANY) ?? [])]; + for (const handler of targets) handler(payload); + } +} + +// The same idea written tersely: the set created inline, and the lookup iterated directly. +export class MiniBus { + private h = new Map void>>(); + + on(k: string, fn: (p: unknown) => void) { + (this.h.get(k) ?? this.h.set(k, new Set()).get(k)!).add(fn); + } + + fire(k: string, p: unknown) { + for (const f of this.h.get(k) ?? []) f(p); + this.h.get('*')?.forEach((f) => f(p)); + } +} diff --git a/tests/cases/typescript/keyed-callback-registry/src/topics.ts b/tests/cases/typescript/keyed-callback-registry/src/topics.ts new file mode 100644 index 00000000..4a2fc759 --- /dev/null +++ b/tests/cases/typescript/keyed-callback-registry/src/topics.ts @@ -0,0 +1,4 @@ +export const TOPIC = { + created: 'item.created', + removed: 'item.removed', +} as const; diff --git a/tests/changed_range.py b/tests/changed_range.py index 5b661576..d82a0203 100644 --- a/tests/changed_range.py +++ b/tests/changed_range.py @@ -10,8 +10,10 @@ control on the base branch itself: no suggestion a new module one `added ` line; no docstring word, no parameter, nothing at line 1 control a function added to an existing file keeps its own line - a copy without git a refusal naming what to pass, not invented "added" declarations - control the same copy with a named file: every declaration in it counts, and its tests are named + a copy without git against the file table its index kept: unchanged is "no change", an edited file counts + whole; read later from a git checkout, a file as the index read it is not an edit + control an edit after that index is reported; with no table, a refusal naming what to pass; a + named file: every declaration in it counts, and its tests are named a changed fixture named as outside the index; it lies in a tree a test reads by path, so it is case data for that test, with that test's own pytest line; files inside the tree are its data control a data file no test names: said so, never "no change" @@ -200,11 +202,32 @@ def ax(repo, *a): shutil.copytree(repo, copy, ignore=shutil.ignore_patterns('.git', '.axiomcode')) built = sh(copy, AX, 'index', '.', '--lang', 'python', env=env) check(built.returncode == 0, 'the copy builds', built.stdout + built.stderr) + # no commit recorded: the baseline is the file table the index wrote (each file's hash as it was read) + rc, out = ax(copy, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'indexed from' in out, 'no git: an unchanged copy right after its index is no change', out) write(copy, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 3')) rc, out = ax(copy, 'changed', '.') - check(rc != 0 and 'no git base' in out and 'added' not in out, 'no git: changed refuses, naming what to pass', out) + check(rc == 0 and 'named' in out and 'price' in out and 'discount' in out and 'level' not in out and 'added' not in out, + 'no git: the file that differs from the index counts whole, the others not at all', out) + rc, out = ax(copy, 'test-impact', '.') + check(rc == 0 and 'test_pricing' in out and 'test_stock' not in out and 'page 1 of' not in out, 'no git: test-impact selects the edited file\'s tests only', out) + # a graph with no commit read from a git checkout (a mirror synced without .git): HEAD is not what was indexed + copy2 = os.path.join(work, 'copy2') + shutil.copytree(copy, copy2, symlinks=True) + write(copy2, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 5')) + for c in (['init', '-q'], ['add', 'app', 'tests'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'older']): sh(copy2, 'git', *c) + write(copy2, 'app/pricing.py', FILES['app/pricing.py']) # the text the copy's index read + rc, out = ax(copy2, 'changed', '.') + check(rc == 0 and 'no change to a declaration' in out and 'price' not in out, + 'no commit recorded, read in a git checkout: a file as the index read it is not an edit against HEAD', out) + write(copy2, 'app/stock.py', FILES['app/stock.py'].replace('return n', 'return n + 0')) + rc, out = ax(copy2, 'changed', '.') + check('level' in out and 'price' not in out, 'control: an edit made after that index is still reported, and only it', out) + for t in ('files.json', 'base-files.json'): os.remove(os.path.join(copy, '.axiomcode', 'out', t)) + rc, out = ax(copy, 'changed', '.') + check(rc != 0 and 'no git base' in out and 'added' not in out, 'control: no git and no file table: changed refuses, naming what to pass', out) rc, out = ax(copy, 'test-impact', '.') - check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'no git: test-impact refuses, with no page footer', out) + check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'control: no git and no file table: test-impact refuses, with no page footer', out) rc, out = ax(copy, 'test-impact', '.', 'app/pricing.py') check(rc == 0 and 'tests/test_pricing.py' in out and 'test_stock' not in out, 'control: test-impact on the copy names that file\'s tests', out) rc, out = ax(copy, 'changed', '.', 'app/pricing.py') diff --git a/tests/freshness.py b/tests/freshness.py index c106676f..08bcd425 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -470,7 +470,7 @@ def query(repo): repo = repo_by('newer', impact=up) s = ax_fresh.status(repo); want = f"graph built by a newer axiomcode (IMPACT_VERSION {up}, this one has {mine})" check("newer: a higher IMPACT_VERSION is a newer build, not an older one to rebuild; with no edit the graph is fresh", - s.get('state') == 'fresh' and s.get('newer') == want and ax_fresh.engine_change(repo) == '', s) + s.get('state') == 'fresh' and s.get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '', s) out, err, took = query(repo) check(f"newer: the answer comes from it at once ({took:.1f}s), unmarked, and says a newer axiomcode built it and it is not rebuilt", out.strip() == ROWS.strip() and want in err and 'not rebuild' in err and 'rebuilding' not in err and took < 10, (out, err)) @@ -482,24 +482,33 @@ def query(repo): # an edit: still never rebuilt; the answer marks the edited file's rows, waits for nothing, and says why no refresh comes open(os.path.join(repo, 'shop/api.py'), 'a').write('\ndef audit(items):\n return total(items)\n') s = ax_fresh.status(repo); out, err, took = query(repo) - check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer') == want, s) + check("newer: with a file edited the graph is stale, and still a newer build", s.get('state') == 'stale' and s.get('newer', '').startswith(want), s) check("newer: with a file edited the refresher still does not rebuild it", not worker(repo), '') check(f"newer: with a file edited the answer marks that file's rows, does not wait ({took:.1f}s), and names no rebuild", 'shop/api.py:5 - calls it' + ax_fresh.MARK in out and want in err and 'predates edits to shop/api.py' in err and 'queued' not in err and 'rebuilding' not in err and 'waiting' not in err and took < 10, (out, err)) w = ax_fresh.wait(repo, 5) - check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", w.get('newer') == want, w) + check("newer: a wait for a fresh graph returns at once rather than waiting for a rebuild that never comes", (w.get('newer') or '').startswith(want), w) # the same IMPACT_VERSION and a later engine is newer too repo = repo_by('newer-engine', engine_version='1.0.1') check("newer: the same IMPACT_VERSION and a later engine version is a newer build", - ax_fresh.status(repo).get('newer') == "graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)" and not worker(repo), + ax_fresh.status(repo).get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") and not worker(repo), ax_fresh.status(repo)) + # AHEAD ON EITHER IS NEWER: a later engine whose IMPACT_VERSION is lower (these scripts newer than the engine that + # AXIOMCODE_ENGINE names) was "built by an older axiomcode (engine 1.0.1 -> 1.0.0)" and rebuilt with the older engine + repo = repo_by('newer-engine-older-export', impact=down, engine_version='1.0.1', engine_hash='0' * 40, engine_stat='0' * 40) + s = ax_fresh.status(repo) + check("newer: a later engine is a newer build even with a lower IMPACT_VERSION, not an older one to rebuild", + s.get('newer', '').startswith("graph built by a newer axiomcode (engine 1.0.1, this one is 1.0.0)") + and ax_fresh.engine_change(repo) == '' and not worker(repo), s) + check("newer: the note says the engine compared is AXIOMCODE_ENGINE, not the axiomcode answering", + f"AXIOMCODE_ENGINE={e1}" in s.get('newer', '') and 'not the axiomcode answering' in s.get('newer', ''), s) # ── composed with the per-language key: NEVER A DOWNGRADE is decided first ── # a newer graph whose own language's rules differ from this engine's is still not rebuilt: that difference is # the newer axiomcode's, not a staleness this one can fix repo = repo_by('newer-own-rules', impact=up, engine_hash='0' * 40, engine_stat='0' * 40) check("newer: a newer build whose own language's engine files differ is still a newer build, not an older one", - ax_fresh.status(repo).get('newer') == want and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) + ax_fresh.status(repo).get('newer', '').startswith(want) and ax_fresh.engine_change(repo) == '' and not worker(repo), ax_fresh.status(repo)) # a higher IMPACT_VERSION is not re-exported either (rewarm): that would record this older version over it worker(repo) check("newer: the refresher does not re-export a newer build's facts; the table keeps the newer IMPACT_VERSION",