From 6453aa5f99e12de900e2de46561d19bd2325bfc4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:35:33 -0700 Subject: [PATCH 01/40] typescript: a JSX attribute hands its function to the element, and a class component tag hands over render and its lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A function written inside a JSX `{…}` — `onPointerMove={this.handleMove}`, `onClick={onSave}`, `renderItem={drawRow}` — was reached by nothing. The parser writes each `{…}` as an expression root of its own, linked to no element (an intrinsic tag has no call site at all), so no hand-over rule saw it, and a handler read off a field or named by reference was cut off from every test that renders the component and fires the event. The `{…}` is now the site that hands the function over, from the function it is written in (callback_registered, as for `xs.map(cb)`); what the value is read through is followed exactly as for an argument. A call there hands over what it returns and stays out. A tag that builds a class component resolved only to its constructor, so render and the lifecycle methods the renderer calls (componentDidMount, componentWillUnmount, …) had no caller, and nor did anything below them. The tag now hands them over too, for a class that has a render method. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../expected/74-jsx-component-forms.edges | 1 + .../expected/80-vue-definecomponent-jsx.edges | 1 + .../80-vue-definecomponent-jsx.lib.edges | 1 + .../engine/call-edge-generation/call_chain.dl | 8 ++ .../engine/resolution/value-flow.dl | 63 +++++++++ graph/typescript/souffle/decls_all.dl | 6 + .../typescript/jsx-handed-functions/case.json | 123 ++++++++++++++++++ .../jsx-handed-functions/src/board.tsx | 49 +++++++ .../jsx-handed-functions/src/jsx.d.ts | 13 ++ .../jsx-handed-functions/src/work.ts | 7 + .../jsx-handed-functions/tsconfig.json | 1 + 11 files changed, 273 insertions(+) create mode 100644 tests/cases/typescript/jsx-handed-functions/case.json create mode 100644 tests/cases/typescript/jsx-handed-functions/src/board.tsx create mode 100644 tests/cases/typescript/jsx-handed-functions/src/jsx.d.ts create mode 100644 tests/cases/typescript/jsx-handed-functions/src/work.ts create mode 100644 tests/cases/typescript/jsx-handed-functions/tsconfig.json diff --git a/graph/test/typescript/expected/74-jsx-component-forms.edges b/graph/test/typescript/expected/74-jsx-component-forms.edges index 8a9a7700b..1215ed2b5 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.edges +++ b/graph/test/typescript/expected/74-jsx-component-forms.edges @@ -10,6 +10,7 @@ callback_registered FUNCTION_CALL app#() @L4 -> app#() callback_registered FUNCTION_CALL parts#() @L33 -> parts#UserCard({ id: string }) callback_registered FUNCTION_CALL parts#() @L41 -> parts#FieldImpl({ label: string },unknown) callback_registered FUNCTION_CALL parts#() @L73 -> parts#buildDefault() +callback_registered JSX_COMPONENT_CALL app#App() @L13 -> Panel#render() intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L4 -> - known_edge FUNCTION_CALL app#useFactory() @L23 -> parts#()@L22 known_edge FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 -> parts#trackClick(string) diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.edges b/graph/test/typescript/expected/80-vue-definecomponent-jsx.edges index 332f06303..15c58497d 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.edges +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.edges @@ -14,6 +14,7 @@ ambiguous_unknown FUNCTION_CALL parts#render() @L35 -> - ambiguous_unknown FUNCTION_CALL reg#() @L18 -> - ambiguous_unknown JSX_COMPONENT_CALL app#() @L20 -> - callback_registered FUNCTION_CALL parts#() @L40 -> parts#(?) +callback_registered JSX_COMPONENT_CALL app#() @L? -> app#onPing() known_edge FUNCTION_CALL app#onPing() @L12 -> app#helper() known_edge FUNCTION_CALL button#() @L10 -> button#leafButton() known_edge FUNCTION_CALL card#render() @L12 -> card#leafCard() diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.lib.edges b/graph/test/typescript/expected/80-vue-definecomponent-jsx.lib.edges index 40aed3c84..92c54f4d1 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.lib.edges +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.lib.edges @@ -14,6 +14,7 @@ boundary_lib FUNCTION_CALL parts#() @L42 -> vue#defineNuxtComponent boundary_lib FUNCTION_CALL parts#() @L49 -> vue#makeStore(ComponentOptions) boundary_lib FUNCTION_CALL reg#() @L18 -> vue#defineComponent(ComponentOptions) callback_registered FUNCTION_CALL parts#() @L40 -> parts#(?) +callback_registered JSX_COMPONENT_CALL app#() @L? -> app#onPing() known_edge FUNCTION_CALL app#onPing() @L12 -> app#helper() known_edge FUNCTION_CALL button#() @L10 -> button#leafButton() known_edge FUNCTION_CALL card#render() @L12 -> card#leafCard() diff --git a/graph/typescript/engine/call-edge-generation/call_chain.dl b/graph/typescript/engine/call-edge-generation/call_chain.dl index 565187993..890a08ad5 100644 --- a/graph/typescript/engine/call-edge-generation/call_chain.dl +++ b/graph/typescript/engine/call-edge-generation/call_chain.dl @@ -134,6 +134,14 @@ call_chain_edge(ce, caller, "-", m, "client", "callback_registered", kind) :- caller != m, invocation_site(ce, kind). +// …and a function written inside a JSX `{…}` (resolution/value-flow.dl, jsx_handed_value). The `{…}` is +// the site: an intrinsic tag has no call site of its own, and the expression positions the edge. +call_chain_edge(r, caller, "-", m, "client", "callback_registered", "JSX_COMPONENT_CALL") :- + jsx_handed_value(r, m), + method_prov(m, "client"), + jsx_root_caller(r, caller), + caller != m. + // ── call_runs_edge(FromMethod, ToMethod, Prov) ────────────────────────────── // The chain a consumer should WALK: overload signatures re-pointed to the body that // actually executes (resolution/overload-sets.dl). Kept separate from diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 75e9d1296..6007a400e 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -294,6 +294,69 @@ proxy_trap_name("preventExtensions"). proxy_trap_name("apply"). proxy_trap_name("construct"). +// …AND A FUNCTION WRITTEN AS A JSX ATTRIBUTE OR CHILD. ``, +// ` + + ) + } +} + +export function mountBoard(): unknown { + return +} + +// a function component handing a module function and a render callback to elements +export function Toolbar(): unknown { + return ( + + ) +} + +export function List(props: { renderItem: (i: number) => string }): unknown { + return
    {props.renderItem.length}
+} + +// CONTROL: a class with a render method that is never written as a tag is not a component: building it runs no render +export class Report { + render(): number { return measure(1) } +} +export function makeReport(): Report { + return new Report() +} + +// CONTROL: an element whose attributes are data hands nothing over +export function Badge(): unknown { + return +} diff --git a/tests/cases/typescript/jsx-handed-functions/src/jsx.d.ts b/tests/cases/typescript/jsx-handed-functions/src/jsx.d.ts new file mode 100644 index 000000000..e5c8fd2d3 --- /dev/null +++ b/tests/cases/typescript/jsx-handed-functions/src/jsx.d.ts @@ -0,0 +1,13 @@ +// the project supplies its own JSX namespace and component base, so the case needs no framework installed +declare global { + namespace JSX { + type Element = unknown + interface ElementClass { render(): unknown } + interface ElementAttributesProperty { props: unknown } + interface IntrinsicElements { [name: string]: unknown } + } +} +export class Component

{ + props: P + constructor(props: P) { this.props = props } +} diff --git a/tests/cases/typescript/jsx-handed-functions/src/work.ts b/tests/cases/typescript/jsx-handed-functions/src/work.ts new file mode 100644 index 000000000..46ad4ace7 --- /dev/null +++ b/tests/cases/typescript/jsx-handed-functions/src/work.ts @@ -0,0 +1,7 @@ +export function hitTest(x: number): number { return x * 2 } +export function persist(id: string): string { return id.trim() } +export function startClock(): number { return Date.now() } +export function stopClock(): number { return 0 } +export function onSave(): string { return "saved" } +export function drawRow(i: number): string { return String(i) } +export function measure(n: number): number { return n + 1 } diff --git a/tests/cases/typescript/jsx-handed-functions/tsconfig.json b/tests/cases/typescript/jsx-handed-functions/tsconfig.json new file mode 100644 index 000000000..f75ffe5b1 --- /dev/null +++ b/tests/cases/typescript/jsx-handed-functions/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "strict": true } } From b2e7cda46558e2427335c50421fdfa98f0e1ea9d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:09:51 -0700 Subject: [PATCH 02/40] typescript: a call through an imported const runs the function it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package's public functions are often its default instance's methods exported as consts: `export const createDraft = immer.createDraft.bind(immer)`, `export const plain = engine.plain`. The holder rules already knew what such a const holds (a method read as a value, through `bind`), and a call to it in the defining module ran that method. A call from any other module names the const through an import, and only a local variable, a parameter or a field was followed — so every caller outside the defining module, its whole test suite included, ran nothing. A call whose callee is an imported const, or a namespace import's member (`api.applyPatches(…)`), now runs what that module's own variable holds. The import is followed to its module, never matched by name: the same exported name bound in another module runs that module's method. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/value-flow.dl | 19 +++ graph/typescript/souffle/decls_all.dl | 1 + .../typescript/bound-method-export/case.json | 110 ++++++++++++++++++ .../bound-method-export/src/engine.ts | 25 ++++ .../bound-method-export/src/other.ts | 8 ++ .../typescript/bound-method-export/src/use.ts | 17 +++ 6 files changed, 180 insertions(+) create mode 100644 tests/cases/typescript/bound-method-export/case.json create mode 100644 tests/cases/typescript/bound-method-export/src/engine.ts create mode 100644 tests/cases/typescript/bound-method-export/src/other.ts create mode 100644 tests/cases/typescript/bound-method-export/src/use.ts diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 6007a400e..284c6e881 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -618,6 +618,20 @@ call_runs(ce, m) :- callee_branch(ce, x), call_runs(ce, m) :- callee_branch(ce, x), ts_field_access_target(x, f), holder_holds_function(f, m). +// A CALL THROUGH AN IMPORTED HOLDER. A package's public functions are often its default instance's methods, +// exported as consts: `export const createDraft = immer.createDraft.bind(immer)`, `export const plain = +// engine.plain`. The holder rules know what the const holds (method_value follows the read and `bind`), but a +// call names the const through an import, and the two clauses above take only a local variable, a parameter +// or a field: every caller outside the defining module — its whole test suite — ran nothing. expr_holder +// follows the import, and a namespace member, to the exporting module's own variable, never by name. +call_runs(ce, m) :- call_callee_expr(_, ce, x), + expr_referenced("client", "IMPORT_BINDING", _, x), + expr_holder(x, h), + holder_holds_function(h, m). +call_runs(ce, m) :- call_callee_expr(_, ce, x), + expr_namespace_member(x), + expr_holder(x, h), + holder_holds_function(h, m). // ============================================================================ // A FUNCTION ASSIGNED TO AN INTERFACE'S METHOD MEMBER IMPLEMENTS IT (#1283) @@ -719,6 +733,11 @@ expr_holder(x, f) :- ts_field_access_target(x, f). // an imported `const` is the exporting module's variable expr_holder(x, v) :- expr_referenced("client", "IMPORT_BINDING", ih, x), import_binds(ih, _, "VARIABLE", v). +// …and so is one read off a namespace import, `api.createDraft` +expr_holder(x, v) :- property_access_recv(x, n, q), + expr_namespace(q, _, mod), + module_export(_, mod, n, "VARIABLE", v). +expr_namespace_member(x) :- property_access_recv(x, _, q), expr_namespace(q, _, _). // ── holder_elem_fn(Holder, Fn): an element of the holder's collection is, or holds, Fn ── holder_elem_fn(h, m) :- holder_elem_value(h, x), diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 005be8144..0b2b39af8 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -220,6 +220,7 @@ .decl collection_receiver(c0:symbol) .decl keyed_collection_ref(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl expr_namespace_member(c0:symbol) .decl jsx_value_root(c0:symbol) .decl jsx_handed_value(c0:symbol,c1:symbol) .decl jsx_root_caller(c0:symbol,c1:symbol) diff --git a/tests/cases/typescript/bound-method-export/case.json b/tests/cases/typescript/bound-method-export/case.json new file mode 100644 index 000000000..bc38818ec --- /dev/null +++ b/tests/cases/typescript/bound-method-export/case.json @@ -0,0 +1,110 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a const bound to an instance method (`engine.createDraft.bind(engine)`) runs that method: a call through it in the same module reaches the method's body", + "run": [ + "path", + "localUse", + "draftStep" + ], + "want": [ + "Engine.createDraft", + "draftStep" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026and through a named import of the const, from another module", + "run": [ + "path", + "draftIt", + "draftStep" + ], + "want": [ + "Engine.createDraft", + "draftStep" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026and through a namespace import", + "run": [ + "path", + "patchIt", + "patchStep" + ], + "want": [ + "Engine.applyPatches", + "patchStep" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "a method read off the instance with no bind is the same method", + "run": [ + "path", + "plainIt", + "plainStep" + ], + "want": [ + "Engine.plain", + "plainStep" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "impact on the method lists the importing caller", + "run": [ + "impact", + "Engine.createDraft" + ], + "want": [ + "draftIt", + "localUse" + ], + "avoid": [ + "nothing calls" + ] + }, + { + "why": "CONTROL: the same exported name bound in another module runs that module's method: the import is followed to its own module's const, never by name", + "run": [ + "path", + "otherIt", + "draftStep" + ], + "want": [ + "no chain of resolved calls" + ], + "avoid": [ + "Engine.createDraft" + ], + "expect_error": true + }, + { + "why": "CONTROL, the positive side: the other module's call reaches its own method", + "run": [ + "path", + "otherIt", + "otherStep" + ], + "want": [ + "Other.createDraft", + "otherStep" + ], + "avoid": [ + "no chain of resolved calls" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/bound-method-export/src/engine.ts b/tests/cases/typescript/bound-method-export/src/engine.ts new file mode 100644 index 000000000..81605ed76 --- /dev/null +++ b/tests/cases/typescript/bound-method-export/src/engine.ts @@ -0,0 +1,25 @@ +export function draftStep(x: number): number { return x + 1 } +export function patchStep(x: number): number { return x - 1 } +export function freezeStep(x: boolean): boolean { return !x } +export function plainStep(x: number): number { return x * 3 } + +export class Engine { + createDraft(x: number): number { return draftStep(x) } + applyPatches(x: number): number { return patchStep(x) } + setFreeze(x: boolean): boolean { return freezeStep(x) } + plain(x: number): number { return plainStep(x) } +} + +const engine = new Engine() + +// the package's public functions are the default instance's methods, bound to it +export const createDraft = /* @__PURE__ */ engine.createDraft.bind(engine) +export const applyPatches = engine.applyPatches.bind(engine) +export let setFreeze = engine.setFreeze.bind(engine) +// read off the instance with no bind +export const plain = engine.plain + +// a call in the same module, through the bound const +export function localUse(): number { + return createDraft(1) +} diff --git a/tests/cases/typescript/bound-method-export/src/other.ts b/tests/cases/typescript/bound-method-export/src/other.ts new file mode 100644 index 000000000..6144da95a --- /dev/null +++ b/tests/cases/typescript/bound-method-export/src/other.ts @@ -0,0 +1,8 @@ +export function otherStep(x: number): number { return x * 5 } + +export class Other { + createDraft(x: number): number { return otherStep(x) } +} +const other = new Other() +// the same exported name, bound to a different instance's method +export const createDraft = other.createDraft.bind(other) diff --git a/tests/cases/typescript/bound-method-export/src/use.ts b/tests/cases/typescript/bound-method-export/src/use.ts new file mode 100644 index 000000000..a5fdc992e --- /dev/null +++ b/tests/cases/typescript/bound-method-export/src/use.ts @@ -0,0 +1,17 @@ +import { createDraft, plain } from "./engine" +import { createDraft as otherDraft } from "./other" +import * as api from "./engine" + +export function draftIt(): number { + return createDraft(2) +} +export function patchIt(): number { + return api.applyPatches(3) +} +export function plainIt(): number { + return plain(4) +} +// CONTROL: the same exported name from another module runs that module's method, not this one's +export function otherIt(): number { + return otherDraft(6) +} From 5c2130a51eb603eb91c556db12d8318037e10eb5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:42:57 -0700 Subject: [PATCH 03/40] typescript: a call on the platform's own objects is no by-name caller of a project method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no standard library staged, `JSON.parse(s)`, `Promise.resolve(v).then(f)`, `Object.keys(o).forEach(g)`, `Reflect.getMetadata(k, t)`, `document.createElement('a')` and `console.error(e)` resolve to nothing, so every one was an untyped `x.parse()` to impact: a project method that happens to be named parse, resolve, all, keys, error or getMetadata got every such line in the repository as a by-name caller, and every test around those lines entered its closure. The engine now traces the receiver to the platform's global object (an AMBIENT_GLOBAL reference to JSON, Promise, Object, Reflect, Array, document, console, …), and what calling it returns, the way it already traces a package's value: the site stays unresolved, is listed apart as a call on a package's or the platform's value, and seeds no closure. A program that declares one of those names itself binds it as its own variable and is unaffected; window, globalThis and self are left out, since a program may hang its own functions on them. An untyped receiver stays a by-name caller. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 37 +++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 1 + .../skills/axiomcode/scripts/ax_edges.py | 2 +- .../skills/axiomcode/scripts/dl/impact.dl | 2 +- .../case.json | 18 +++++++++ .../src/any.test.ts | 4 ++ .../src/app.ts | 17 +++++++++ .../src/config.test.ts | 4 ++ .../src/parser.ts | 7 ++++ 9 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/builtin-receiver-is-not-a-caller/case.json create mode 100644 tests/cases/typescript/builtin-receiver-is-not-a-caller/src/any.test.ts create mode 100644 tests/cases/typescript/builtin-receiver-is-not-a-caller/src/app.ts create mode 100644 tests/cases/typescript/builtin-receiver-is-not-a-caller/src/config.test.ts create mode 100644 tests/cases/typescript/builtin-receiver-is-not-a-caller/src/parser.ts diff --git a/graph/typescript/engine/call-edge-generation/calls.dl b/graph/typescript/engine/call-edge-generation/calls.dl index f90275c34..b4b87c8f0 100644 --- a/graph/typescript/engine/call-edge-generation/calls.dl +++ b/graph/typescript/engine/call-edge-generation/calls.dl @@ -151,5 +151,42 @@ library_value(e, spec) :- expr_kind("client", k, _, e), library_value(e, spec) :- expr_referenced("client", "VARIABLE", v, e), var_initializer("client", _, i, v), !var_reassigned(v), library_value(i, spec). call_passes_project_instance(e) :- expr_child("client", e, "ARGUMENT", _, a), expr_type(a, "client", _). +// …and the runtime's own objects: `JSON.parse(s)`, `Promise.resolve(v).then(f)`, `Object.keys(o).forEach(g)`, +// `document.createElement('a')`, `console.error(e)`. With no standard library staged the name resolves to nothing +// in the run (AMBIENT_GLOBAL), so every such call was an untyped `x.parse()` to the consumers, and a project method +// that happens to be named parse, resolve, all, keys, error or getMetadata was given every one of them as a by-name +// caller — and every test around them. The receiver is the platform's, never the project's: a program that declares +// the name itself binds it as its own variable, not as an ambient global, and stays out. Only the objects whose +// members are called through the name; `window`, `globalThis` and `self` are left out, since a program may hang its +// own functions on them. +library_value(e, n) :- expr_referenced("client", "AMBIENT_GLOBAL", _, e), expr_name("client", n, e), + builtin_global_object(n). +builtin_global_object("JSON"). +builtin_global_object("Math"). +builtin_global_object("Object"). +builtin_global_object("Reflect"). +builtin_global_object("Promise"). +builtin_global_object("Array"). +builtin_global_object("Number"). +builtin_global_object("String"). +builtin_global_object("Boolean"). +builtin_global_object("BigInt"). +builtin_global_object("Symbol"). +builtin_global_object("Date"). +builtin_global_object("RegExp"). +builtin_global_object("Error"). +builtin_global_object("Intl"). +builtin_global_object("Atomics"). +builtin_global_object("ArrayBuffer"). +builtin_global_object("console"). +builtin_global_object("process"). +builtin_global_object("Buffer"). +builtin_global_object("document"). +builtin_global_object("navigator"). +builtin_global_object("localStorage"). +builtin_global_object("sessionStorage"). +builtin_global_object("performance"). +builtin_global_object("crypto"). +builtin_global_object("URL"). library_receiver(ce, spec) :- call_site("client", _, _, rk, recv, ce, _), receiver_kind_is_value(rk), !call_resolved(ce), library_value(recv, spec). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 0b2b39af8..500217c8e 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -619,6 +619,7 @@ .decl unresolved_covered(c0:symbol) .decl package_import(c0:symbol,c1:symbol) .decl library_value(c0:symbol,c1:symbol) +.decl builtin_global_object(c0:symbol) .decl call_passes_project_instance(c0:symbol) .decl library_receiver(c0:symbol,c1:symbol) .decl unresolved_receiver(c0:symbol,c1:symbol,c2:symbol,c3:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index f5283b76a..533319b38 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -503,7 +503,7 @@ def nearest(cands): # the candidates enclosing no other candidat # unknown, but a project `get` is not what it calls. Like a stub it keeps a row, apart from the untyped-receiver name # matches, and it seeds no closure: listed among them it filled the first page of `impact Repo.get`. LIBRARY_RECEIVER_KIND = 'library' -LIBRARY_BYNAME_WHY = 'calls a method of this name on a value a package returned or constructed: not this method, unless the package hands it back' +LIBRARY_BYNAME_WHY = 'calls a method of this name on a value a package or the platform (JSON, Promise, document …) returned or constructed: not this method, unless that code hands it back' def library_receiver_sites(q): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f8d70e364..7910a4f8f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -424,7 +424,7 @@ direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by nam direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). // A site whose receiver the engine traced to a package's value (`request(app).get('/x')`, ax_edges.library_receiver_sites): // the kind "library". A row of its own, listed after the untyped-receiver matches, and no seed of the closure. -direct(q, c, "uses", "calls a method of this name on a value a package returned or constructed: not this method, unless the package hands it back", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "library", f, l), !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name on a value a package or the platform (JSON, Promise, document …) returned or constructed: not this method, unless that code hands it back", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "library", f, l), !is_target_decl(q, c). // ── a method handed over as a VALUE, never called where it is named ────────────────────────────────────────── // `app.get('/orders/:id', getOrder)`, `useEffect(load)`, `items.map(format)`, `handlers = {"x": handle_x}`: the diff --git a/tests/cases/typescript/builtin-receiver-is-not-a-caller/case.json b/tests/cases/typescript/builtin-receiver-is-not-a-caller/case.json new file mode 100644 index 000000000..1f010be6a --- /dev/null +++ b/tests/cases/typescript/builtin-receiver-is-not-a-caller/case.json @@ -0,0 +1,18 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "JSON.parse is the platform's: the function that writes it is listed apart as a call on the platform's value, not as a by-name caller of the project's parse, and the test around it is not a test of that method", + "run": ["impact", "FrameParser.parse", "--tests"], + "want": ["readAny", "any.test.ts", "a value a package or the platform"], + "avoid": ["config.test.ts"]}, + {"why": "what a built-in call returns is the platform's too: Object.keys(o).forEach is the array's forEach, so the project's forEach has no by-name caller", + "run": ["impact", "FrameParser.forEach"], + "want": ["a value a package or the platform"], + "avoid": ["calls a method of this name (receiver not typed)"]}, + {"why": "Promise.resolve is the platform's: the project's Queue.resolve keeps only its untyped caller, and only that caller's test", + "run": ["impact", "Queue.resolve", "--tests"], + "want": ["drainAny", "any.test.ts"], + "avoid": ["config.test.ts"]}, + {"why": "CONTROL: an untyped receiver is still a by-name caller, and its test is still selected", + "run": ["impact", "FrameParser.parse", "--tests"], + "want": ["calls a method of this name (receiver not typed)", "any.test.ts"]} + ]} diff --git a/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/any.test.ts b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/any.test.ts new file mode 100644 index 000000000..0796c6244 --- /dev/null +++ b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/any.test.ts @@ -0,0 +1,4 @@ +import { readAny, drainAny } from "./app" +declare function it(name: string, fn: () => void): void +it("reads through anything", () => { readAny({ parse: () => 1 }, "x") }) +it("drains anything", () => { drainAny({ resolve: () => "y" }) }) diff --git a/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/app.ts b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/app.ts new file mode 100644 index 000000000..0584ce981 --- /dev/null +++ b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/app.ts @@ -0,0 +1,17 @@ +// the runtime's own objects: none of these calls can land on the project's parse, forEach or resolve +export function readConfig(raw: string): unknown { + return JSON.parse(raw) +} +export function listKeys(o: object): void { + Object.keys(o).forEach((k) => console.log(k)) +} +export function later(v: number): Promise { + return Promise.resolve(v).then((x) => x + 1) +} +// CONTROL: a receiver the engine cannot type may be a FrameParser: still a by-name caller +export function readAny(p: any, raw: string): unknown { + return p.parse(raw) +} +export function drainAny(q: any): unknown { + return q.resolve("x") +} diff --git a/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/config.test.ts b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/config.test.ts new file mode 100644 index 000000000..0df979555 --- /dev/null +++ b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/config.test.ts @@ -0,0 +1,4 @@ +import { readConfig, later } from "./app" +declare function it(name: string, fn: () => void): void +it("reads the config", () => { readConfig("{}") }) +it("waits", () => { later(1) }) diff --git a/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/parser.ts b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/parser.ts new file mode 100644 index 000000000..1ef5aeb3e --- /dev/null +++ b/tests/cases/typescript/builtin-receiver-is-not-a-caller/src/parser.ts @@ -0,0 +1,7 @@ +export class FrameParser { + parse(text: string): number { return text.length } + forEach(f: (n: number) => void): void { f(1) } +} +export class Queue { + resolve(id: string): string { return id } +} From 92d7ceee017bf774a990dc291b34b3296088c248 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:07:22 -0700 Subject: [PATCH 04/40] fix(parser/js): an import resolves to the code it runs, not a declaration beside it The compiler's resolver prefers a declaration file: require('../') in a package whose package.json names `types` (or an exports "types" condition), and import './x.js' beside a hand-written x.d.ts, both resolved to the .d.ts. JavaScript never loads a declaration, so every value imported that way was external and every call through it an untyped receiver: a test of the package's own API reached nothing in it. When the first answer is a declaration outside node_modules, the import is resolved again with declarations hidden from the resolver (types/typings and the .d.ts substitution fall through to main, the exports code conditions and the .js itself). A module that exists only as a declaration keeps its first answer. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../extractors/js-module-edge-extractor.ts | 44 ++++++++++++- .../declaration-beside-the-code/case.json | 62 +++++++++++++++++++ .../declaration-beside-the-code/src/index.js | 3 + .../src/lib/format.d.ts | 1 + .../src/lib/format.js | 2 + .../src/lib/geometry.js | 2 + .../src/lib/reader.js | 5 ++ .../src/lib/shape.d.ts | 1 + .../src/package.json | 2 + .../src/test/format.test.js | 2 + .../src/test/reader.test.js | 5 ++ .../src/test/shape.test.js | 2 + .../src/typings/index.d.ts | 2 + 13 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/cases/javascript/declaration-beside-the-code/case.json create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/index.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/lib/format.d.ts create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/lib/format.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/lib/geometry.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/lib/reader.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/lib/shape.d.ts create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/package.json create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/test/format.test.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/test/reader.test.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/test/shape.test.js create mode 100644 tests/cases/javascript/declaration-beside-the-code/src/typings/index.d.ts diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index 50bd20c41..d07fcb1cd 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -1214,8 +1214,20 @@ class JsModuleEdgeExtractor { ).resolvedModule?.resolvedFileName; // tsc resolves no `.vue` import itself (its extensions are fixed), so a // component in this program is looked up by path. - const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) + let resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath, this.options.compilerOptions); + // The compiler prefers a DECLARATION to the code it describes: `require('../')` in a package + // whose package.json names `types: typings/index.d.ts`, or `import './lib.js'` beside a + // hand-written `lib.d.ts`, resolves to the .d.ts. JavaScript never loads a declaration, so + // the module it runs is asked for again with declarations out of sight; left alone, every + // value imported that way was external and each call through it untyped. + if (resolved !== undefined && isDeclarationPath(resolved) + && !resolved.includes(`${path.sep}node_modules${path.sep}`) && !resolved.includes('/node_modules/')) { + const runtime = resolveRuntimeFile(specifier, this.options.absoluteFilePath, this.options.compilerOptions, mode); + if (runtime !== undefined) { + resolved = runtime; + } + } // BOTH SIDES CANONICAL. `projectModuleHashes` is keyed by the files the analyzer // walked from a root it has already resolved through its symlinks; the resolver // answers with the real path for a package found under `node_modules` but does NOT @@ -1280,6 +1292,36 @@ class JsModuleEdgeExtractor { // --------------------------------------------------------------------------- +const DECLARATION_PATH = /\.d\.[cm]?ts$/; + +function isDeclarationPath(file: string): boolean { + return DECLARATION_PATH.test(file); +} + +/** + * The file a JavaScript runtime would load for `specifier`: the compiler's resolution with every + * declaration file hidden from it, so `types`/`typings` and a `.d.ts` beside a `.js` fall through to + * `main`, the `exports` code conditions and the `.js` itself. `undefined` when nothing but a + * declaration answers (a types-only package), which leaves the first answer standing. + */ +function resolveRuntimeFile( + specifier: string, + fromFile: string, + options: ts.CompilerOptions, + mode: ts.ResolutionMode +): string | undefined { + const host: ts.ModuleResolutionHost = { + ...ts.sys, + fileExists: (f) => !isDeclarationPath(f) && ts.sys.fileExists(f), + }; + const answer = (m: ts.ResolutionMode): string | undefined => { + const r = ts.resolveModuleName(specifier, fromFile, { ...options, allowJs: true }, host, undefined, undefined, m) + .resolvedModule?.resolvedFileName; + return r !== undefined && !isDeclarationPath(r) ? r : undefined; + }; + return answer(mode) ?? (mode === ts.ModuleKind.ESNext ? answer(ts.ModuleKind.CommonJS) : undefined); +} + /** `createRequire(import.meta.url)`, however it was imported. */ function isCreateRequireCall(node: ts.CallExpression): boolean { const callee = node.expression; diff --git a/tests/cases/javascript/declaration-beside-the-code/case.json b/tests/cases/javascript/declaration-beside-the-code/case.json new file mode 100644 index 000000000..2257c3b4d --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/case.json @@ -0,0 +1,62 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "require('../') in a package whose package.json names a hand-written declaration (types / an exports types condition) loads index.js at runtime: the test that calls the class it exports reaches the method", + "run": [ + "impact", + "Reader.skipBlank", + "--tests" + ], + "want": [ + "reader.test.js", + "tests: 1 of" + ], + "avoid": [ + "tests: 0 of" + ] + }, + { + "why": "require('../lib/format.js') beside a format.d.ts loads the .js: the test's call is an exact edge, not a name match", + "run": [ + "path", + "src/test/format.test.js", + "wrap" + ], + "want": [ + "known_edge" + ], + "avoid": [ + "receiver not typed", + "callee is" + ] + }, + { + "why": "path from the test file is an exact chain through the runtime module, not an unknown receiver", + "run": [ + "path", + "src/test/reader.test.js", + "Reader.readAll" + ], + "want": [ + "known_edge" + ], + "avoid": [ + "receiver not typed" + ] + }, + { + "why": "CONTROL: a module that exists only as a declaration has no code to run; a call through it stays unresolved and is never linked to a same-named project method", + "run": [ + "path", + "src/test/shape.test.js", + "Square.area" + ], + "expect_error": true, + "avoid": [ + "known_edge" + ] + } + ] +} diff --git a/tests/cases/javascript/declaration-beside-the-code/src/index.js b/tests/cases/javascript/declaration-beside-the-code/src/index.js new file mode 100644 index 000000000..5343d15f5 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/index.js @@ -0,0 +1,3 @@ +const { Reader } = require('./lib/reader.js'); +exports.Reader = Reader; +exports.reader = new Reader(); diff --git a/tests/cases/javascript/declaration-beside-the-code/src/lib/format.d.ts b/tests/cases/javascript/declaration-beside-the-code/src/lib/format.d.ts new file mode 100644 index 000000000..a3746a069 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/lib/format.d.ts @@ -0,0 +1 @@ +export function wrap(text: string): string; diff --git a/tests/cases/javascript/declaration-beside-the-code/src/lib/format.js b/tests/cases/javascript/declaration-beside-the-code/src/lib/format.js new file mode 100644 index 000000000..c2f695206 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/lib/format.js @@ -0,0 +1,2 @@ +function wrap(text) { return `[${text}]`; } +module.exports = { wrap }; diff --git a/tests/cases/javascript/declaration-beside-the-code/src/lib/geometry.js b/tests/cases/javascript/declaration-beside-the-code/src/lib/geometry.js new file mode 100644 index 000000000..a7895a606 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/lib/geometry.js @@ -0,0 +1,2 @@ +class Square { area() { return 4; } } +module.exports = { Square }; diff --git a/tests/cases/javascript/declaration-beside-the-code/src/lib/reader.js b/tests/cases/javascript/declaration-beside-the-code/src/lib/reader.js new file mode 100644 index 000000000..e4e128603 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/lib/reader.js @@ -0,0 +1,5 @@ +class Reader { + readAll(argv) { return this.skipBlank(argv); } + skipBlank(argv) { return argv.length; } +} +exports.Reader = Reader; diff --git a/tests/cases/javascript/declaration-beside-the-code/src/lib/shape.d.ts b/tests/cases/javascript/declaration-beside-the-code/src/lib/shape.d.ts new file mode 100644 index 000000000..68e7e66a2 --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/lib/shape.d.ts @@ -0,0 +1 @@ +export interface Shape { area(): number; } diff --git a/tests/cases/javascript/declaration-beside-the-code/src/package.json b/tests/cases/javascript/declaration-beside-the-code/src/package.json new file mode 100644 index 000000000..572becf2f --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/package.json @@ -0,0 +1,2 @@ +{"name": "readkit", "main": "./index.js", "types": "./typings/index.d.ts", + "exports": {".": {"require": {"types": "./typings/index.d.ts", "default": "./index.js"}, "default": "./index.js"}}} diff --git a/tests/cases/javascript/declaration-beside-the-code/src/test/format.test.js b/tests/cases/javascript/declaration-beside-the-code/src/test/format.test.js new file mode 100644 index 000000000..29eecc05c --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/test/format.test.js @@ -0,0 +1,2 @@ +const { wrap } = require('../lib/format.js'); +test('wrap', () => { expect(wrap('a')).toBe('[a]'); }); diff --git a/tests/cases/javascript/declaration-beside-the-code/src/test/reader.test.js b/tests/cases/javascript/declaration-beside-the-code/src/test/reader.test.js new file mode 100644 index 000000000..d204209cc --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/test/reader.test.js @@ -0,0 +1,5 @@ +const kit = require('../'); +test('readAll', () => { + const reader = new kit.Reader(); + expect(() => reader.readAll(['x'])).not.toThrow(); +}); diff --git a/tests/cases/javascript/declaration-beside-the-code/src/test/shape.test.js b/tests/cases/javascript/declaration-beside-the-code/src/test/shape.test.js new file mode 100644 index 000000000..77f0280ac --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/test/shape.test.js @@ -0,0 +1,2 @@ +const shape = require('../lib/shape'); +test('shape', () => { expect(shape.area()).toBe(1); }); diff --git a/tests/cases/javascript/declaration-beside-the-code/src/typings/index.d.ts b/tests/cases/javascript/declaration-beside-the-code/src/typings/index.d.ts new file mode 100644 index 000000000..b647e452c --- /dev/null +++ b/tests/cases/javascript/declaration-beside-the-code/src/typings/index.d.ts @@ -0,0 +1,2 @@ +export class Reader { readAll(argv: string[]): number; } +export const reader: Reader; From 21c0f9fc0b1570c0e31b2d9c1dde25ad1ca65c3e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:28:41 -0700 Subject: [PATCH 05/40] typescript: a list's element is typed through a type variable's constraint and an object-type alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugins.map((p) => p.setup(app))` left `p` with nothing to dispatch on in two common shapes: - the element is an object type written as an alias (`type Plugin = { setup(): … }`): the no-lib callback binding gave the parameter the element's TYPE only, and an alias of an object type has no type declaration, only members — so every call on it was unresolved, while an annotated parameter of the same type resolved through its shape. The callback parameter now takes the element's shape too. - the list is typed by a type variable constrained to an array or tuple (`(...modules: Ms)`): its element had no type at all. The constraint is what every element is at least, as it already is for a `` receiver; the element is now read through it, for callbacks and for `for…of`, by the exact link to the type parameter or by its name in scope. A plugin/module system built this way — a builder that maps over its modules and calls `init` — was reached by none of the tests that drive it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/contextual-params.dl | 21 +++ .../engine/resolution/type-resolution.dl | 13 ++ .../generic-list-element-type/case.json | 128 ++++++++++++++++++ .../generic-list-element-type/src/modules.ts | 31 +++++ .../generic-list-element-type/src/plugins.ts | 21 +++ .../generic-list-element-type/tsconfig.json | 1 + 6 files changed, 215 insertions(+) create mode 100644 tests/cases/typescript/generic-list-element-type/case.json create mode 100644 tests/cases/typescript/generic-list-element-type/src/modules.ts create mode 100644 tests/cases/typescript/generic-list-element-type/src/plugins.ts create mode 100644 tests/cases/typescript/generic-list-element-type/tsconfig.json diff --git a/graph/typescript/engine/resolution/contextual-params.dl b/graph/typescript/engine/resolution/contextual-params.dl index c9f675e15..99b05b5a1 100644 --- a/graph/typescript/engine/resolution/contextual-params.dl +++ b/graph/typescript/engine/resolution/contextual-params.dl @@ -278,6 +278,20 @@ builtin_elem_ref(parent, x) :- ref_is_readonly_operator(parent), builtin_elem_ref(child, x). builtin_elem_ref(ref, x) :- ref_via_alias(ref, rhs), builtin_elem_ref(rhs, x). +// A tuple's elements are its members: `[Module, ...Module[]]`. +builtin_elem_ref(ref, child) :- type_ref(rp, "TUPLE", _, _, _, _, _, ref), + type_ref_parent(rp, ref, _, child). +// A TYPE VARIABLE constrained to a list holds that list's elements: `(ms: Ms)`, +// `(...modules: Ms)`, then `modules.map((m) => m.init())`. The +// constraint is what every element is at least, as for a `` receiver. Both routes +// ref_type_target takes: the exact link to the declaration, and the name in scope where it is absent. +builtin_elem_ref(ref, x) :- type_ref_type_param(rp, tp, ref), + type_param_constraint(rp, cref, tp), + builtin_elem_ref(cref, x). +builtin_elem_ref(ref, x) :- type_ref(_, "TYPE_VARIABLE", _, tn, _, owner, _, ref), + !type_ref_type_param(_, _, ref), + type_var_constraint_in_scope(owner, tn, cref), + builtin_elem_ref(cref, x). builtin_elem_ref(ref, a) :- ref_via_alias(ref, rhs), builtin_elem_ref(rhs, x), type_ref(_, "TYPE_VARIABLE", _, n, _, _, _, x), @@ -339,6 +353,13 @@ lambda_param_ref(lp, c) :- call_site("client", _, "then", _, recv, ce, _), !param_type_ref(_, _, lp). lambda_param_type(lp, prov, t) :- lambda_param_ref(lp, c), ref_type_target(c, prov, t). +// …and its SHAPE, where the element is an object type written as a type alias or a literal rather +// than a class or an interface: `type Plugin = { init(): void }`, then `plugins.map((p) => p.init())`. +// The element has no type declaration to dispatch on, only members, so the type clause above gave +// the parameter nothing and every call on it was unresolved; an annotated parameter of the same type +// resolved, through param_shape_target. The element reference is the same one, read as a shape. +lambda_param_shape(lp, sh) :- lambda_param_ref(lp, c), + ref_shape_target(c, sh). // The parameter's reference is its declared reference, as an annotation's would be. expr_decl_ref(e, c) :- expr_referenced("client", "PARAMETER", p, e), lambda_param_ref(p, c). diff --git a/graph/typescript/engine/resolution/type-resolution.dl b/graph/typescript/engine/resolution/type-resolution.dl index 2c8e5ef20..0d3cff9f9 100644 --- a/graph/typescript/engine/resolution/type-resolution.dl +++ b/graph/typescript/engine/resolution/type-resolution.dl @@ -587,6 +587,19 @@ ref_element_target(ref, prov, t) :- type_ref(rp, k, _, _, _, _, _, ref), ref_kind_is_array(k), type_ref_parent(rp, ref, _, child), ref_type_target(child, prov, t). +// …and of a TYPE VARIABLE constrained to an array or a tuple: `(ms: Ms)`, +// `(...modules: Ms)`, then `modules.map((m) => m.init())`. The +// constraint is what every element is at least, exactly as it is for a `` receiver +// (ref_type_target above), so the callback's parameter is typed by the constraint's element. Without it +// the element of a generic list had no type, and every call on it was unresolved. Same two routes as +// ref_type_target: the exact link to the declaration, and the name in scope where the link is absent. +ref_element_target(ref, prov, t) :- type_ref_type_param(rp, tp, ref), + type_param_constraint(rp, cref, tp), + ref_element_target(cref, prov, t). +ref_element_target(ref, prov, t) :- type_ref(_, "TYPE_VARIABLE", _, tn, _, owner, _, ref), + !type_ref_type_param(_, _, ref), + type_var_constraint_in_scope(owner, tn, cref), + ref_element_target(cref, prov, t). // ── ref_element_shape(Ref, Shape) — an element that is ITSELF a function type ── // `fns: readonly ((data: T) => boolean)[]` then `fns.every((fn) => fn(1))`. The element diff --git a/tests/cases/typescript/generic-list-element-type/case.json b/tests/cases/typescript/generic-list-element-type/case.json new file mode 100644 index 000000000..76b81bfb4 --- /dev/null +++ b/tests/cases/typescript/generic-list-element-type/case.json @@ -0,0 +1,128 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a list typed by `Ms extends Module[]` holds Modules: a call on its element dispatches on Module, and reaches the implementations", + "run": [ + "path", + "buildAll", + "coreInit" + ], + "want": [ + "coreInit" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026and for a rest parameter typed by a non-empty tuple constraint", + "run": [ + "path", + "buildCreate", + "reactInit" + ], + "want": [ + "reactInit" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026and through `readonly`", + "run": [ + "path", + "each", + "coreInit" + ], + "want": [ + "coreInit" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026and a for-of over the same list: the binding is an element, typed by the constraint", + "run": [ + "path", + "loopAll", + "coreInit" + ], + "want": [ + "coreInit" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "a list of an object-type alias: the callback's parameter takes the element's shape, so a call on it dispatches to what implements that shape", + "run": [ + "path", + "install", + "routerSetup" + ], + "want": [ + "routerSetup" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "\u2026through a type variable constrained to a tuple of them, iterated in a returned closure", + "run": [ + "path", + "start", + "routerSetup" + ], + "want": [ + "routerSetup" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "CONTROL: a list of another shape with a member of the same name reaches its own implementation, not Plugin's", + "run": [ + "path", + "runHooks", + "routerSetup" + ], + "want": [ + "no chain of resolved calls" + ], + "expect_error": true + }, + { + "why": "CONTROL, the positive side: that list reaches its own shape's implementation", + "run": [ + "path", + "runHooks", + "storeSetup" + ], + "want": [ + "storeSetup" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "CONTROL: an element typed `unknown` by its constraint names no type: nothing is dispatched to Auditor.audit's implementation", + "run": [ + "path", + "loose", + "auditInit" + ], + "want": [ + "no chain of resolved calls" + ], + "expect_error": true + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/generic-list-element-type/src/modules.ts b/tests/cases/typescript/generic-list-element-type/src/modules.ts new file mode 100644 index 000000000..6e377d46e --- /dev/null +++ b/tests/cases/typescript/generic-list-element-type/src/modules.ts @@ -0,0 +1,31 @@ +export interface Module { name: string; init(): number } +export function coreInit(): number { return 1 } +export function reactInit(): number { return 2 } +export function auditInit(): number { return 3 } + +export const coreModule = (): Module => ({ name: "core", init() { return coreInit() } }) +export const reactModule = (): Module => ({ name: "react", init() { return reactInit() } }) + +// a list typed by a type parameter constrained to an array, or to a non-empty tuple, then iterated +export function buildAll(modules: Ms): number[] { + return modules.map((m) => m.init()) +} +export function buildCreate(...modules: Ms): number[] { + return modules.map((m) => m.init()) +} +export function each(modules: Ms): void { + modules.forEach((m) => { m.init() }) +} + +export interface Auditor { audit(): number } +export const auditor = (): Auditor => ({ audit() { return auditInit() } }) +// CONTROL: an unconstrained type variable has no element type: the call on its element stays unresolved +export function loose(xs: Ms): unknown[] { + return xs.map((x) => (x as any).audit()) +} + +export function loopAll(modules: Ms): number { + let n = 0 + for (const m of modules) n += m.init() + return n +} diff --git a/tests/cases/typescript/generic-list-element-type/src/plugins.ts b/tests/cases/typescript/generic-list-element-type/src/plugins.ts new file mode 100644 index 000000000..4221175c3 --- /dev/null +++ b/tests/cases/typescript/generic-list-element-type/src/plugins.ts @@ -0,0 +1,21 @@ +// an object type written as a type alias: the element has members and no declaration to dispatch on +export type Plugin = { name: Name; setup(app: unknown): number } +export function routerSetup(): number { return 4 } +export function storeSetup(): number { return 5 } +export const routerPlugin = (): Plugin<"router"> => ({ name: "router", setup() { return routerSetup() } }) + +export function install(plugins: Plugin[]): number[] { + return plugins.map((p) => p.setup(null)) +} +// a generic list of them, iterated in a closure the builder returns +export function buildApp, ...Plugin[]]>(...plugins: Ps) { + return function start(app: unknown): number[] { + return plugins.map((p) => p.setup(app)) + } +} +// CONTROL: the element's shape is not a Plugin: its setup is not Plugin's, so nothing reaches routerSetup +export type Hook = { setup(): number } +export const storeHook = (): Hook => ({ setup() { return storeSetup() } }) +export function runHooks(hooks: Hook[]): number[] { + return hooks.map((h) => h.setup()) +} diff --git a/tests/cases/typescript/generic-list-element-type/tsconfig.json b/tests/cases/typescript/generic-list-element-type/tsconfig.json new file mode 100644 index 000000000..bfebedc51 --- /dev/null +++ b/tests/cases/typescript/generic-list-element-type/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } From 33885874498d7ec0aad113418c0983ee02ecf40c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:07:22 -0700 Subject: [PATCH 06/40] fix(js): a test file's calls do not decide whether the program's arguments flow The parameter fan cap refuses argument flow into a function called from more than --dispatch-cap places, and it counted every site, tests included. A suite calls the API it tests over and over, so exactly the functions under test went "hot": a constructor called from 21 test files beside 13 library sites lost its parameters, a callback the library handed it and stored on the instance (this.decode = options['decode']) was never followed, and the library's own call through the field resolved only to the fallback beside it. Two verdicts now. Whether the PROGRAM's arguments flow is decided by the program's own sites; whether a TEST's arguments flow is decided by every site, as before, so nothing that flowed before stops flowing: a plugin a test hands the program, which the program calls back with its own class, still types what the test then calls (a version that dropped every test argument lost exactly that; the case guards it). For the name count, the overcount used for module-level functions, a test's bare call or `new` of the name leaves it, while a test's member call that only shares the name (`lib.add(1, 2)` on an export that is a different function, such as a curried closure) stays in it: dropping those too made a curried library's graph three times larger and its index five times slower, for no change in any answer. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/engine/resolution/fan-cap.dl | 36 +++++++++++- .../engine/resolution/value-flow.dl | 19 +++++-- graph/javascript/souffle/decls_all.dl | 8 +++ .../tests-do-not-make-the-api-hot/case.json | 55 +++++++++++++++++++ .../src/lib/codec.js | 7 +++ .../src/lib/codecs/alpha.js | 4 ++ .../src/lib/codecs/beta.js | 4 ++ .../src/lib/codecs/gamma.js | 4 ++ .../src/lib/core.js | 5 ++ .../src/lib/each.js | 3 + .../src/lib/loader.js | 10 ++++ .../src/lib/plugins/shout.js | 4 ++ .../src/lib/walk.js | 29 ++++++++++ .../src/package.json | 1 + .../src/test/custom.test.js | 25 +++++++++ .../src/test/load.test.js | 2 + .../src/test/plugin.test.js | 4 ++ .../src/test/walk.test.js | 2 + 18 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/case.json create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codec.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/alpha.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/beta.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/gamma.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/core.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/each.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/loader.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/plugins/shout.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/walk.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/package.json create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/custom.test.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/load.test.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/plugin.test.js create mode 100644 tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/walk.test.js diff --git a/graph/javascript/engine/resolution/fan-cap.dl b/graph/javascript/engine/resolution/fan-cap.dl index af6bdd930..ad69acdb3 100644 --- a/graph/javascript/engine/resolution/fan-cap.dl +++ b/graph/javascript/engine/resolution/fan-cap.dl @@ -80,14 +80,48 @@ direct_export_func(mod, n, m) :- export_decl(_, n, _, _, _, _, _, se, mod, _), s // shares its name with hundreds of unrelated sites and must not be capped by them, // while a module-level `each` or `merge` called under its own name from everywhere // is exactly the generic utility the cap exists for. +// +// The PROGRAM's arguments are decided by the program's sites alone. A suite calls the +// API it tests over and over, and counted with the program those calls made exactly the +// functions under test "generic utilities": their parameters went untracked, so a +// callback the program hands a constructor and stores on the instance (`this.decode = +// options.decode`) was lost, and the program's own call through that field resolved only +// to the fallback beside it. So there are two verdicts: +// method_is_hot every site counts (as before): decides whether a TEST's +// arguments flow in +// method_is_hot_in_program test files' sites do not count: decides whether the +// PROGRAM's arguments flow in +// Nothing that flowed before stops flowing (a test handing the program a plugin it calls +// back still reaches it). A function hot only because of its tests now receives what the +// program passes it and still nothing from the tests: their callbacks would be the widest +// values it holds, and with them the call through the stored field would be over the +// dispatch cap and answer nothing. param_flow_capped_at(n) :- dispatch_cap(s), n = to_number(s). direct_call_count(m, n) :- direct_call_site(_, m), n = count : { direct_call_site(_, m) }. method_is_hot(m, n) :- direct_call_count(m, n), param_flow_capped_at(c), n > c. name_call_count(cn, n) :- call_site(_, _, cn, _, _, _, _, _, _), cn != "", n = count : { call_site(_, _, cn, _, _, _, _, _, _) }. +program_direct_site(ce, m) :- direct_call_site(ce, m), !site_in_test_module(ce). +program_direct_count(m, n) :- program_direct_site(_, m), n = count : { program_direct_site(_, m) }. +method_is_hot_in_program(m, n) :- program_direct_count(m, n), param_flow_capped_at(c), n > c. +// The NAME count is the overcount for module-level functions. A test's member call that +// only shares the name (`lib.add(1, 2)` on an export that is a different function, a +// curried closure) stays in it, as before; a test's bare call or `new` of the name +// (`build(...)`, `new lib.Codec(...)`) denotes the function itself and leaves it. +test_names_itself(ce) :- site_in_test_module(ce), call_site(_, ck, _, _, _, _, ce, _, _), call_kind_is_callee_form(ck). +test_names_itself(ce) :- site_in_test_module(ce), call_site(_, "CONSTRUCTOR_CALL", _, _, _, _, ce, _, _). +program_name_site(m, ce) :- module_level_function(m, cn), call_site(_, _, cn, _, _, _, ce, _, _), !test_names_itself(ce). +program_name_count(m, n) :- program_name_site(m, _), n = count : { program_name_site(m, _) }. +// ── test_module / site_in_test_module ────────────── +// By path, as runners find their files: a `test`/`tests`/`__tests__`/`spec(s)` directory, +// or a `.test.` / `.spec.` file. +test_module(mod) :- module_file("client", fp, mod), + match("(.*/)?(tests?|__tests__|specs?)/.*|.*[.](test|spec)[.][cm]?[jt]sx?", fp). +site_in_test_module(ce) :- expr_owner(_, _, mod, ce), test_module(mod). module_level_function(m, n) :- method_decl(_, n, "FUNCTION_DECLARATION", _, _, "", mod, m), method_scopes(_, _, _, init, m), module_init(_, init, mod). module_level_function(m, n) :- var_decl(_, n, _, _, _, mod, v), function_binding_value(v, m), method_decl(_, _, _, _, _, "", mod, m), method_scopes(_, _, _, init, m), module_init(_, init, mod). method_is_hot(m, n) :- module_level_function(m, cn), name_call_count(cn, n), param_flow_capped_at(c), n > c. +method_is_hot_in_program(m, n) :- program_name_count(m, n), param_flow_capped_at(c), n > c. // ── param_fan_capped(ParamHash, SiteCount) — countable ────────────────────── -param_fan_capped(p, n) :- method_is_hot(m, n), param_decl(_, _, _, m, p). +param_fan_capped(p, n) :- method_is_hot_in_program(m, n), param_decl(_, _, _, m, p). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 2c79e52ba..e78863e26 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -264,8 +264,15 @@ param_value(p, "inst", t) :- param_type_name(_, _, tr, p), type_ref_type(tr, t). // values on every export, a 20-minute solve — because a generic library is exactly // the shape whose parameters unify everything. What a library does with a callback it // is handed is a MODEL (resolution/frameworks.dl), stated per library, not inferred. -param_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), !method_is_hot(m, _), - method_decl("client", _, _, _, _, _, _, m), +// The program's sites pass their arguments unless the PROGRAM calls the function from +// more places than the cap; a test file's sites only while every site together stays +// under it (fan-cap.dl). +param_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), !site_in_test_module(ce), + !method_is_hot_in_program(m, _), method_decl("client", _, _, _, _, _, _, m), + param_decl(_, _, ppos, m, p), to_number(ppos) = pos, !param_is_rest(_, p), + expr_value(arg, k, i). +param_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), site_in_test_module(ce), + !method_is_hot(m, _), method_decl("client", _, _, _, _, _, _, m), param_decl(_, _, ppos, m, p), to_number(ppos) = pos, !param_is_rest(_, p), expr_value(arg, k, i). // THE ONE LIBRARY EXCEPTION: a UMD wrapper (#710). `(function (root, factory) { @@ -283,8 +290,12 @@ param_value(p, "func", f) :- call_site(_, "IIFE_CALL", _, _, _, _, ce, _, _), ca expr_value(arg, "func", f). // A rest parameter IS an array; the arguments at and past its position are its elements. param_value(p, "arr", p) :- param_is_rest(_, p). -elem_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), !method_is_hot(m, _), - method_decl("client", _, _, _, _, _, _, m), +elem_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), !site_in_test_module(ce), + !method_is_hot_in_program(m, _), method_decl("client", _, _, _, _, _, _, m), + param_decl(_, _, ppos, m, p), param_is_rest(_, p), to_number(ppos) <= pos, + expr_value(arg, k, i). +elem_value(p, k, i) :- call_arg(ce, pos, arg), expr_resolves_to_method(ce, m), site_in_test_module(ce), + !method_is_hot(m, _), method_decl("client", _, _, _, _, _, _, m), param_decl(_, _, ppos, m, p), param_is_rest(_, p), to_number(ppos) <= pos, expr_value(arg, k, i). // A default value: `cb = () => {}`. The parser gives the parameter the default's diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index b7b566473..e30d8c1f8 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -462,6 +462,14 @@ .decl param_fan_capped(c0:symbol, c1:number) .decl import_binding_is_default_like(c0:symbol) .decl name_call_count(c0:symbol, c1:number) +.decl test_module(c0:symbol) +.decl site_in_test_module(c0:symbol) +.decl program_direct_site(c0:symbol, c1:symbol) +.decl program_direct_count(c0:symbol, c1:number) +.decl method_is_hot_in_program(c0:symbol, c1:number) +.decl test_names_itself(c0:symbol) +.decl program_name_site(c0:symbol, c1:symbol) +.decl program_name_count(c0:symbol, c1:number) .decl module_level_function(c0:symbol, c1:symbol) .decl ambient_collection(c0:symbol) .decl coll_method_call(c0:symbol, c1:symbol, c2:symbol) diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/case.json b/tests/cases/javascript/tests-do-not-make-the-api-hot/case.json new file mode 100644 index 000000000..cd47315d2 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/case.json @@ -0,0 +1,55 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a constructor the tests call 24 times is not a generic utility: the program's own `new Codec(name, {decode})` reaches the parameter, so `codec.decode()` in the loader calls the stored callback and the loader's test reaches it", + "run": [ + "impact", + "decode_alpha", + "--tests" + ], + "want": [ + "load", + "load.test.js" + ], + "avoid": [ + "tests: 0 of" + ] + }, + { + "why": "a test's argument does not flow into the program's parameter: the loader's call is one of the three library callbacks and the fallback, never a test's own decoder", + "run": [ + "impact", + "customDecode3" + ], + "avoid": [ + "load " + ] + }, + { + "why": "CONTROL: a utility the PROGRAM calls from 23 places stays capped: what it is handed is not followed, so the callback is not a callee of each", + "run": [ + "impact", + "onItem" + ], + "avoid": [ + "each " + ] + }, + { + "why": "a test still hands the program what it calls back: a plugin passed from a test to core.extend is called with the class, so the method it adds is what the test's `core().shout()` calls (what flowed before keeps flowing)", + "run": [ + "path", + "src/test/plugin.test.js", + "shout" + ], + "want": [ + "known_edge" + ], + "avoid": [ + "receiver not typed" + ] + } + ] +} diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codec.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codec.js new file mode 100644 index 000000000..cbf366d78 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codec.js @@ -0,0 +1,7 @@ +'use strict'; +function Codec(name, options) { + options = options || {}; + this.name = name; + this.decode = options['decode'] || function () { return true; }; +} +module.exports = Codec; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/alpha.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/alpha.js new file mode 100644 index 000000000..a5864f2ed --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/alpha.js @@ -0,0 +1,4 @@ +'use strict'; +var Codec = require('../codec'); +function decode_alpha(data) { return data === 'alpha'; } +module.exports = new Codec('codec:alpha', { decode: decode_alpha }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/beta.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/beta.js new file mode 100644 index 000000000..8b97967ea --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/beta.js @@ -0,0 +1,4 @@ +'use strict'; +var Codec = require('../codec'); +function decode_beta(data) { return data === 'beta'; } +module.exports = new Codec('codec:beta', { decode: decode_beta }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/gamma.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/gamma.js new file mode 100644 index 000000000..e1d9291d5 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/codecs/gamma.js @@ -0,0 +1,4 @@ +'use strict'; +var Codec = require('../codec'); +function decode_gamma(data) { return data === 'gamma'; } +module.exports = new Codec('codec:gamma', { decode: decode_gamma }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/core.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/core.js new file mode 100644 index 000000000..8424af35c --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/core.js @@ -0,0 +1,5 @@ +'use strict'; +class Core {} +function core() { return new Core(); } +core.extend = (plugin) => { plugin(Core); return core; }; +module.exports = core; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/each.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/each.js new file mode 100644 index 000000000..94936ac25 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/each.js @@ -0,0 +1,3 @@ +'use strict'; +function each(list, fn) { for (var i = 0; i < list.length; i++) fn(list[i]); } +module.exports = each; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/loader.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/loader.js new file mode 100644 index 000000000..2086d5321 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/loader.js @@ -0,0 +1,10 @@ +'use strict'; +var codecs = [require('./codecs/alpha'), require('./codecs/beta'), require('./codecs/gamma')]; +function load(x) { + for (var i = 0; i < codecs.length; i++) { + var codec = codecs[i]; + if (codec.decode(x)) return codec.name; + } + return null; +} +module.exports = { load, Codec: require('./codec') }; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/plugins/shout.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/plugins/shout.js new file mode 100644 index 000000000..21df4a2e0 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/plugins/shout.js @@ -0,0 +1,4 @@ +'use strict'; +module.exports = (C) => { + C.prototype.shout = function shout() { return 'HEY'; }; +}; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/walk.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/walk.js new file mode 100644 index 000000000..bee2917a1 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/lib/walk.js @@ -0,0 +1,29 @@ +'use strict'; +var each = require('./each'); +function onItem(x) { return x; } +function walk(list) { + each(list, function step0(x) { return x + 0; }); + each(list, function step1(x) { return x + 1; }); + each(list, function step2(x) { return x + 2; }); + each(list, function step3(x) { return x + 3; }); + each(list, function step4(x) { return x + 4; }); + each(list, function step5(x) { return x + 5; }); + each(list, function step6(x) { return x + 6; }); + each(list, function step7(x) { return x + 7; }); + each(list, function step8(x) { return x + 8; }); + each(list, function step9(x) { return x + 9; }); + each(list, function step10(x) { return x + 10; }); + each(list, function step11(x) { return x + 11; }); + each(list, function step12(x) { return x + 12; }); + each(list, function step13(x) { return x + 13; }); + each(list, function step14(x) { return x + 14; }); + each(list, function step15(x) { return x + 15; }); + each(list, function step16(x) { return x + 16; }); + each(list, function step17(x) { return x + 17; }); + each(list, function step18(x) { return x + 18; }); + each(list, function step19(x) { return x + 19; }); + each(list, function step20(x) { return x + 20; }); + each(list, function step21(x) { return x + 21; }); + each(list, onItem); +} +module.exports = { walk, onItem }; diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/package.json b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/package.json new file mode 100644 index 000000000..8b109c46d --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/package.json @@ -0,0 +1 @@ +{"name": "codeckit"} diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/custom.test.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/custom.test.js new file mode 100644 index 000000000..d64224b8f --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/custom.test.js @@ -0,0 +1,25 @@ +const { Codec } = require('../lib/loader'); +test('custom 0', () => { const t = new Codec('!c0', { decode: function customDecode0(d) { return d === 0; } }); expect(t.decode(0)).toBe(true); }); +test('custom 1', () => { const t = new Codec('!c1', { decode: function customDecode1(d) { return d === 1; } }); expect(t.decode(1)).toBe(true); }); +test('custom 2', () => { const t = new Codec('!c2', { decode: function customDecode2(d) { return d === 2; } }); expect(t.decode(2)).toBe(true); }); +test('custom 3', () => { const t = new Codec('!c3', { decode: function customDecode3(d) { return d === 3; } }); expect(t.decode(3)).toBe(true); }); +test('custom 4', () => { const t = new Codec('!c4', { decode: function customDecode4(d) { return d === 4; } }); expect(t.decode(4)).toBe(true); }); +test('custom 5', () => { const t = new Codec('!c5', { decode: function customDecode5(d) { return d === 5; } }); expect(t.decode(5)).toBe(true); }); +test('custom 6', () => { const t = new Codec('!c6', { decode: function customDecode6(d) { return d === 6; } }); expect(t.decode(6)).toBe(true); }); +test('custom 7', () => { const t = new Codec('!c7', { decode: function customDecode7(d) { return d === 7; } }); expect(t.decode(7)).toBe(true); }); +test('custom 8', () => { const t = new Codec('!c8', { decode: function customDecode8(d) { return d === 8; } }); expect(t.decode(8)).toBe(true); }); +test('custom 9', () => { const t = new Codec('!c9', { decode: function customDecode9(d) { return d === 9; } }); expect(t.decode(9)).toBe(true); }); +test('custom 10', () => { const t = new Codec('!c10', { decode: function customDecode10(d) { return d === 10; } }); expect(t.decode(10)).toBe(true); }); +test('custom 11', () => { const t = new Codec('!c11', { decode: function customDecode11(d) { return d === 11; } }); expect(t.decode(11)).toBe(true); }); +test('custom 12', () => { const t = new Codec('!c12', { decode: function customDecode12(d) { return d === 12; } }); expect(t.decode(12)).toBe(true); }); +test('custom 13', () => { const t = new Codec('!c13', { decode: function customDecode13(d) { return d === 13; } }); expect(t.decode(13)).toBe(true); }); +test('custom 14', () => { const t = new Codec('!c14', { decode: function customDecode14(d) { return d === 14; } }); expect(t.decode(14)).toBe(true); }); +test('custom 15', () => { const t = new Codec('!c15', { decode: function customDecode15(d) { return d === 15; } }); expect(t.decode(15)).toBe(true); }); +test('custom 16', () => { const t = new Codec('!c16', { decode: function customDecode16(d) { return d === 16; } }); expect(t.decode(16)).toBe(true); }); +test('custom 17', () => { const t = new Codec('!c17', { decode: function customDecode17(d) { return d === 17; } }); expect(t.decode(17)).toBe(true); }); +test('custom 18', () => { const t = new Codec('!c18', { decode: function customDecode18(d) { return d === 18; } }); expect(t.decode(18)).toBe(true); }); +test('custom 19', () => { const t = new Codec('!c19', { decode: function customDecode19(d) { return d === 19; } }); expect(t.decode(19)).toBe(true); }); +test('custom 20', () => { const t = new Codec('!c20', { decode: function customDecode20(d) { return d === 20; } }); expect(t.decode(20)).toBe(true); }); +test('custom 21', () => { const t = new Codec('!c21', { decode: function customDecode21(d) { return d === 21; } }); expect(t.decode(21)).toBe(true); }); +test('custom 22', () => { const t = new Codec('!c22', { decode: function customDecode22(d) { return d === 22; } }); expect(t.decode(22)).toBe(true); }); +test('custom 23', () => { const t = new Codec('!c23', { decode: function customDecode23(d) { return d === 23; } }); expect(t.decode(23)).toBe(true); }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/load.test.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/load.test.js new file mode 100644 index 000000000..9cffc0a66 --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/load.test.js @@ -0,0 +1,2 @@ +const { load } = require('../lib/loader'); +test('alpha', () => { expect(load('alpha')).toBe('codec:alpha'); }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/plugin.test.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/plugin.test.js new file mode 100644 index 000000000..f4478092a --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/plugin.test.js @@ -0,0 +1,4 @@ +const core = require('../lib/core'); +const shoutPlugin = require('../lib/plugins/shout'); +core.extend(shoutPlugin); +test('shout', () => { expect(core().shout()).toBe('HEY'); }); diff --git a/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/walk.test.js b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/walk.test.js new file mode 100644 index 000000000..4234f11fa --- /dev/null +++ b/tests/cases/javascript/tests-do-not-make-the-api-hot/src/test/walk.test.js @@ -0,0 +1,2 @@ +const { walk } = require('../lib/walk'); +test('walk', () => { walk([1]); }); From 408d01f3102538b611c059ba4241fef8f47a4da1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:07:22 -0700 Subject: [PATCH 07/40] fix(js): a call of what a project wrapper returned runs what that site was handed `export default promisable(eachItem, 3)`, `const add = curry2(function add(a, b) {...})`: the wrapper returns a closure that calls its parameter, and the module exports that closure. The closure's call of the parameter is read context-insensitively, so it held every function any of the wrapper's sites passed (past the fan cap: nothing), and a test calling the export reached the closure and stopped there. The wrapping site is already a value of its own (("wrap", site), value-flow.dl), used for what a call through it returns. A call through it now also runs the function that site was handed at the forwarded position, what the wrapper writes into that parameter, or what a wrapped argument itself runs: one of a set, beside the edge to the closure the call keeps, and under the dispatch cap (a parameter every wrapped export is passed to runs one of them, and naming all of them says nothing). A wrapper whose closure never calls its parameter is not a wrapper and runs nothing it was handed. Two engine goldens gain these edges (memoize / once / lazy results and the param-reassigned shapes), reviewed and blessed with --oracle. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 23 +++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 4 ++++ .../expected/64-memoize-wrapper-result.edges | 19 +++++++++++++++ .../65-wrapper-param-reassigned.edges | 15 ++++++++++++ .../case.json | 16 +++++++++++++ .../src/lib/index.js | 3 +++ .../src/lib/label.js | 3 +++ .../src/lib/named.js | 3 +++ .../src/lib/op0.js | 3 +++ .../src/lib/op1.js | 3 +++ .../src/lib/op10.js | 3 +++ .../src/lib/op11.js | 3 +++ .../src/lib/op12.js | 3 +++ .../src/lib/op13.js | 3 +++ .../src/lib/op14.js | 3 +++ .../src/lib/op15.js | 3 +++ .../src/lib/op16.js | 3 +++ .../src/lib/op17.js | 3 +++ .../src/lib/op18.js | 3 +++ .../src/lib/op19.js | 3 +++ .../src/lib/op2.js | 3 +++ .../src/lib/op20.js | 3 +++ .../src/lib/op21.js | 3 +++ .../src/lib/op22.js | 3 +++ .../src/lib/op23.js | 3 +++ .../src/lib/op3.js | 3 +++ .../src/lib/op4.js | 3 +++ .../src/lib/op5.js | 3 +++ .../src/lib/op6.js | 3 +++ .../src/lib/op7.js | 3 +++ .../src/lib/op8.js | 3 +++ .../src/lib/op9.js | 3 +++ .../src/lib/promisable.js | 12 ++++++++++ .../src/package.json | 1 + .../src/test/named.test.js | 2 ++ .../src/test/op3.test.js | 2 ++ .../src/test/op4.test.js | 2 ++ 37 files changed, 177 insertions(+) create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/case.json create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/index.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/label.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/named.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op0.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op1.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op10.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op11.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op12.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op13.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op14.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op15.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op16.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op17.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op18.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op19.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op2.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op20.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op21.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op22.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op23.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op3.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op4.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op5.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op6.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op7.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op8.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op9.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/promisable.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/package.json create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/named.test.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op3.test.js create mode 100644 tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op4.test.js diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index 39dbe7891..5745b29db 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -170,6 +170,29 @@ call_chain_edge(ce, caller, "-", m, "client", "multi_inferred", kind) :- wrapper_call_target(ce, m), !call_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). variable_reassigned(v) :- expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt). +// ── a call of what a PROJECT wrapper returned runs what that site was handed ── +// `export default promisable(eachItem, 3)`, `const add = curry2(function add(a, b) {…})`: +// the wrapper returns a closure that calls its parameter, and every export of the module +// is that closure. The closure's call of the parameter is read context-insensitively, so +// it held every function any of the wrapper's sites passed (past the fan cap: nothing), +// and a test calling the export reached the closure and stopped there. The site is +// already a value of its own, ("wrap", site), beside the closure (value-flow.dl), and a +// call through it takes its RESULT from that site's argument alone; the same site says +// what the call RUNS: the function it was handed at the forwarded position, what the +// wrapper writes into that parameter, or what a wrapped argument itself runs +// (`memoize(once(f))`). One of a set, beside the edge to the closure the call keeps, and +// under the same fan cap: a callee that may hold more wrapped values than --dispatch-cap +// (a parameter every wrapped export is passed to) runs one of them, and naming all of +// them says nothing. +wrap_runs(s, g) :- wrap_site_arg(s, _, arg), expr_value(arg, "func", g). +wrap_runs(s, g) :- wrap_site_arg(s, _, arg), expr_value(arg, "wrap", s2), wrap_runs(s2, g). +wrap_runs(s, g) :- wrap_site(s, h, pos), wrapper_param_written(h, pos, o), expr_value(o, "func", g). +wrapped_call_target(ce, g) :- callee_value(ce, "wrap", s), wrap_runs(s, g), method_prov(g, "client"), + !call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _). +wrapped_target_count(ce, n) :- wrapped_call_target(ce, _), n = count : { wrapped_call_target(ce, _) }. +wrapped_over_cap(ce) :- wrapped_target_count(ce, n), dispatch_cap(c), n > to_number(c). +call_chain_edge(ce, caller, "-", g, "client", "multi_inferred", kind) :- + wrapped_call_target(ce, g), !call_over_cap(ce), !wrapped_over_cap(ce), call_from(ce, caller), invocation_site(ce, kind). // `(c ? a : b)()`, `(0, cb)()`, `make()()`: the callee is computed by an expression. // `new Function(s)()` / `Function(s)()` runs code built at run time. unresolved_value_callee(ce, "dynamic_code") :- value_callee_unresolved(ce, c), callee_builds_code(c). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index e30d8c1f8..bc489cb6e 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -112,6 +112,10 @@ .decl wrapper_holder_call(c0:symbol, c1:symbol) .decl wrapper_runs(c0:symbol, c1:symbol) .decl wrapper_call_target(c0:symbol, c1:symbol) +.decl wrap_runs(c0:symbol, c1:symbol) +.decl wrapped_call_target(c0:symbol, c1:symbol) +.decl wrapped_target_count(c0:symbol, c1:number) +.decl wrapped_over_cap(c0:symbol) .decl live_export_variable(c0:symbol, c1:symbol) .decl this_type_open(c0:symbol) diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.edges b/graph/test/javascript/expected/64-memoize-wrapper-result.edges index c3d46e256..46a76a118 100644 --- a/graph/test/javascript/expected/64-memoize-wrapper-result.edges +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.edges @@ -1,6 +1,8 @@ app.js:11:3 FUNCTION_CALL getAlpha -> known_edge memoize.js:5:10 +app.js:11:3 FUNCTION_CALL getAlpha -> multi_inferred app.js:7:26 app.js:11:3 METHOD_CALL getAlpha().getHooks -> known_edge alpha.js:2:3 getHooks app.js:12:3 FUNCTION_CALL getBeta -> known_edge memoize.js:5:10 +app.js:12:3 FUNCTION_CALL getBeta -> multi_inferred app.js:8:25 app.js:12:3 METHOD_CALL getBeta().getHooks -> known_edge beta.js:2:3 getHooks app.js:16:20 FUNCTION_CALL once -> callback_registered app.js:16:25 app.js:16:20 FUNCTION_CALL once -> known_edge once.js:2:1 once @@ -15,12 +17,16 @@ app.js:19:19 FUNCTION_CALL lazy -> callback_registered app.js:19:24 app.js:19:19 FUNCTION_CALL lazy -> known_edge once.js:14:1 lazy app.js:19:30 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - app.js:22:3 FUNCTION_CALL firstAlpha -> known_edge once.js:5:10 +app.js:22:3 FUNCTION_CALL firstAlpha -> multi_inferred app.js:16:25 app.js:22:3 METHOD_CALL firstAlpha().run -> known_edge alpha.js:3:3 run app.js:23:3 FUNCTION_CALL makeBeta -> known_edge once.js:15:10 +app.js:23:3 FUNCTION_CALL makeBeta -> multi_inferred app.js:17:23 app.js:23:3 METHOD_CALL makeBeta().run -> known_edge beta.js:3:3 run app.js:24:3 FUNCTION_CALL firstBeta -> known_edge once.js:5:10 +app.js:24:3 FUNCTION_CALL firstBeta -> multi_inferred app.js:18:24 app.js:24:3 METHOD_CALL firstBeta().run -> known_edge beta.js:3:3 run app.js:25:3 FUNCTION_CALL makeAlpha -> known_edge once.js:15:10 +app.js:25:3 FUNCTION_CALL makeAlpha -> multi_inferred app.js:19:24 app.js:25:3 METHOD_CALL makeAlpha().run -> known_edge alpha.js:3:3 run app.js:29:18 FUNCTION_CALL constant -> known_edge once.js:18:1 constant app.js:29:27 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - @@ -57,10 +63,14 @@ declared.js:27:16 FUNCTION_CALL pickShared -> callback_registered declared.js declared.js:27:16 FUNCTION_CALL pickShared -> known_edge declared.js:20:1 pickShared declared.js:27:33 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - declared.js:30:3 FUNCTION_CALL declAlpha -> known_edge declared.js:9:3 inner +declared.js:30:3 FUNCTION_CALL declAlpha -> multi_inferred declared.js:22:28 declared.js:30:3 METHOD_CALL declAlpha().run -> known_edge alpha.js:3:3 run declared.js:31:3 FUNCTION_CALL declBeta -> known_edge declared.js:9:3 inner +declared.js:31:3 FUNCTION_CALL declBeta -> multi_inferred declared.js:23:27 declared.js:31:3 METHOD_CALL declBeta().run -> known_edge beta.js:3:3 run declared.js:32:3 FUNCTION_CALL declOfGetter -> known_edge declared.js:9:3 inner +declared.js:32:3 FUNCTION_CALL declOfGetter -> multi_inferred declared.js:24:26 +declared.js:32:3 FUNCTION_CALL declOfGetter -> multi_inferred memoize.js:5:10 declared.js:32:3 METHOD_CALL declOfGetter().run -> known_edge alpha.js:3:3 run declared.js:33:3 FUNCTION_CALL tappedDecl -> known_edge declared.js:15:3 inner declared.js:33:3 METHOD_CALL tappedDecl().markOnly -> known_edge declared.js:13:14 markOnly @@ -202,24 +212,33 @@ shapes.js:45:27 FUNCTION_CALL memoize -> callback_registered shapes.js:45:35 shapes.js:45:27 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 shapes.js:45:41 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - shapes.js:48:3 FUNCTION_CALL getFb -> known_edge shapes.js:15:10 +shapes.js:48:3 FUNCTION_CALL getFb -> multi_inferred shapes.js:36:28 shapes.js:48:3 METHOD_CALL getFb().run -> known_edge alpha.js:3:3 run shapes.js:49:3 FUNCTION_CALL getFb -> known_edge shapes.js:15:10 +shapes.js:49:3 FUNCTION_CALL getFb -> multi_inferred shapes.js:36:28 shapes.js:49:3 METHOD_CALL getFb().fallbackOnly -> known_edge shapes.js:8:18 fallbackOnly shapes.js:50:3 FUNCTION_CALL getOr -> known_edge shapes.js:22:10 +shapes.js:50:3 FUNCTION_CALL getOr -> multi_inferred shapes.js:38:22 shapes.js:50:3 METHOD_CALL getOr().run -> ambient_terminal - shapes.js:50:3 METHOD_CALL getOr().run -> multi_inferred beta.js:3:3 run shapes.js:51:3 FUNCTION_CALL getOr -> known_edge shapes.js:22:10 +shapes.js:51:3 FUNCTION_CALL getOr -> multi_inferred shapes.js:38:22 shapes.js:51:3 METHOD_CALL getOr().mockOnly -> ambient_terminal - shapes.js:51:3 METHOD_CALL getOr().mockOnly -> multi_inferred shapes.js:9:14 mockOnly shapes.js:52:3 FUNCTION_CALL getD -> known_edge shapes.js:29:10 +shapes.js:52:3 FUNCTION_CALL getD -> multi_inferred shapes.js:39:26 shapes.js:52:3 METHOD_CALL getD().run -> known_edge alpha.js:3:3 run shapes.js:53:3 FUNCTION_CALL getD -> known_edge shapes.js:29:10 +shapes.js:53:3 FUNCTION_CALL getD -> multi_inferred shapes.js:39:26 shapes.js:53:3 METHOD_CALL getD().defaultsOnly -> known_edge shapes.js:25:20 defaultsOnly shapes.js:54:3 FUNCTION_CALL getDefault -> known_edge shapes.js:33:44 shapes.js:54:3 METHOD_CALL getDefault().defaultOnly -> known_edge shapes.js:10:19 defaultOnly shapes.js:55:3 FUNCTION_CALL getSpread -> known_edge shapes.js:33:44 shapes.js:55:3 METHOD_CALL getSpread().run -> ambiguous_unknown - shapes.js:56:3 FUNCTION_CALL warm -> known_edge shapes.js:34:32 +shapes.js:56:3 FUNCTION_CALL warm -> multi_inferred shapes.js:43:20 shapes.js:56:3 METHOD_CALL warm().run -> known_edge alpha.js:3:3 run shapes.js:57:3 FUNCTION_CALL getNested -> known_edge memoize.js:5:10 +shapes.js:57:3 FUNCTION_CALL getNested -> multi_inferred memoize.js:5:10 +shapes.js:57:3 FUNCTION_CALL getNested -> multi_inferred shapes.js:45:35 shapes.js:57:3 METHOD_CALL getNested().run -> known_edge beta.js:3:3 run diff --git a/graph/test/javascript/expected/65-wrapper-param-reassigned.edges b/graph/test/javascript/expected/65-wrapper-param-reassigned.edges index d56c695d6..8373c4e36 100644 --- a/graph/test/javascript/expected/65-wrapper-param-reassigned.edges +++ b/graph/test/javascript/expected/65-wrapper-param-reassigned.edges @@ -2,51 +2,66 @@ app.js:10:23 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:10:3 FUNCTION_CALL lazyOrDefault -> callback_registered app.js:10:17 app.js:10:3 FUNCTION_CALL lazyOrDefault -> known_edge wrap.js:17:1 lazyOrDefault app.js:10:3 FUNCTION_CALL lazyOrDefault(() => new A()) -> known_edge wrap.js:19:10 +app.js:10:3 FUNCTION_CALL lazyOrDefault(() => new A()) -> multi_inferred app.js:10:17 +app.js:10:3 FUNCTION_CALL lazyOrDefault(() => new A()) -> multi_inferred wrap.js:18:15 app.js:10:3 METHOD_CALL lazyOrDefault(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly app.js:11:3 FUNCTION_CALL lazyNullish -> known_edge wrap.js:22:1 lazyNullish app.js:11:3 FUNCTION_CALL lazyNullish() -> known_edge wrap.js:24:10 +app.js:11:3 FUNCTION_CALL lazyNullish() -> multi_inferred wrap.js:23:10 app.js:11:3 METHOD_CALL lazyNullish()().dfltOnly -> known_edge wrap.js:4:14 dfltOnly app.js:15:15 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:15:3 FUNCTION_CALL plain -> callback_registered app.js:15:9 app.js:15:3 FUNCTION_CALL plain -> known_edge wrap.js:27:1 plain app.js:15:3 FUNCTION_CALL plain(() => new A()) -> known_edge wrap.js:28:10 +app.js:15:3 FUNCTION_CALL plain(() => new A()) -> multi_inferred app.js:15:9 app.js:15:3 METHOD_CALL plain(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly app.js:16:15 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:16:3 FUNCTION_CALL plain -> callback_registered app.js:16:9 app.js:16:3 FUNCTION_CALL plain -> known_edge wrap.js:27:1 plain app.js:16:3 FUNCTION_CALL plain(() => new A()) -> known_edge wrap.js:28:10 +app.js:16:3 FUNCTION_CALL plain(() => new A()) -> multi_inferred app.js:16:9 app.js:16:3 METHOD_CALL plain(() => new A())().replacedOnly -> ambiguous_unknown - app.js:17:22 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:17:3 FUNCTION_CALL otherWritten -> callback_registered app.js:17:16 app.js:17:3 FUNCTION_CALL otherWritten -> known_edge wrap.js:31:1 otherWritten app.js:17:3 FUNCTION_CALL otherWritten(() => new A(), null) -> known_edge wrap.js:33:10 +app.js:17:3 FUNCTION_CALL otherWritten(() => new A(), null) -> multi_inferred app.js:17:16 app.js:17:3 METHOD_CALL otherWritten(() => new A(), null)().aOnly -> known_edge wrap.js:2:11 aOnly app.js:18:22 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:18:3 FUNCTION_CALL otherWritten -> callback_registered app.js:18:16 app.js:18:3 FUNCTION_CALL otherWritten -> known_edge wrap.js:31:1 otherWritten app.js:18:3 FUNCTION_CALL otherWritten(() => new A(), null) -> known_edge wrap.js:33:10 +app.js:18:3 FUNCTION_CALL otherWritten(() => new A(), null) -> multi_inferred app.js:18:16 app.js:18:3 METHOD_CALL otherWritten(() => new A(), null)().replacedOnly -> ambiguous_unknown - app.js:4:14 FUNCTION_CALL swapped -> callback_registered app.js:4:22 app.js:4:14 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped app.js:4:28 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:5:3 FUNCTION_CALL sw -> known_edge wrap.js:9:10 +app.js:5:3 FUNCTION_CALL sw -> multi_inferred wrap.js:8:8 app.js:5:3 METHOD_CALL sw().replacedOnly -> known_edge wrap.js:3:18 replacedOnly app.js:6:3 FUNCTION_CALL sw -> known_edge wrap.js:9:10 +app.js:6:3 FUNCTION_CALL sw -> multi_inferred wrap.js:8:8 app.js:6:3 METHOD_CALL sw().aOnly -> ambiguous_unknown - app.js:7:3 FUNCTION_CALL lazyGuard -> known_edge wrap.js:12:1 lazyGuard app.js:7:3 FUNCTION_CALL lazyGuard(undefined) -> known_edge wrap.js:14:10 +app.js:7:3 FUNCTION_CALL lazyGuard(undefined) -> multi_inferred wrap.js:13:38 app.js:7:3 METHOD_CALL lazyGuard(undefined)().replacedOnly -> known_edge wrap.js:3:18 replacedOnly app.js:8:19 CONSTRUCTOR_CALL A -> implicit_constructor - app.js:8:3 FUNCTION_CALL lazyGuard -> callback_registered app.js:8:13 app.js:8:3 FUNCTION_CALL lazyGuard -> known_edge wrap.js:12:1 lazyGuard app.js:8:3 FUNCTION_CALL lazyGuard(() => new A()) -> known_edge wrap.js:14:10 +app.js:8:3 FUNCTION_CALL lazyGuard(() => new A()) -> multi_inferred app.js:8:13 +app.js:8:3 FUNCTION_CALL lazyGuard(() => new A()) -> multi_inferred wrap.js:13:38 app.js:8:3 METHOD_CALL lazyGuard(() => new A())().aOnly -> known_edge wrap.js:2:11 aOnly app.js:9:3 FUNCTION_CALL lazyOrDefault -> known_edge wrap.js:17:1 lazyOrDefault app.js:9:3 FUNCTION_CALL lazyOrDefault() -> known_edge wrap.js:19:10 +app.js:9:3 FUNCTION_CALL lazyOrDefault() -> multi_inferred wrap.js:18:15 app.js:9:3 METHOD_CALL lazyOrDefault()().dfltOnly -> known_edge wrap.js:4:14 dfltOnly many.js:11:3 FUNCTION_CALL s21 -> known_edge wrap.js:9:10 +many.js:11:3 FUNCTION_CALL s21 -> multi_inferred wrap.js:8:8 many.js:11:3 METHOD_CALL s21().replacedOnly -> known_edge wrap.js:3:18 replacedOnly many.js:12:3 FUNCTION_CALL s21 -> known_edge wrap.js:9:10 +many.js:12:3 FUNCTION_CALL s21 -> multi_inferred wrap.js:8:8 many.js:12:3 METHOD_CALL s21().aOnly -> ambiguous_unknown - many.js:3:13 FUNCTION_CALL swapped -> callback_registered many.js:3:21 many.js:3:13 FUNCTION_CALL swapped -> known_edge wrap.js:7:1 swapped diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/case.json b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/case.json new file mode 100644 index 000000000..dcb660275 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/case.json @@ -0,0 +1,16 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "calling what `promisable(op3Impl, 2)` returned runs op3Impl: the test that calls the export reaches the function the module wrapped", + "run": ["impact", "op3Impl", "--tests"], + "want": ["op3.test.js"], + "avoid": ["tests: 0 of", "op4.test.js"]}, + {"why": "the same through a member of a re-exporting object: lib.op4(...) runs op4Impl, and no other wrapped function", + "run": ["impact", "op4Impl", "--tests"], + "want": ["op4.test.js"], + "avoid": ["op3.test.js"]}, + {"why": "the wrapper's own call of its parameter still holds every wrapped function only per site: op3's test is not a test of op5Impl", + "run": ["impact", "op5Impl", "--tests"], + "avoid": ["op3.test.js", "op4.test.js"]}, + {"why": "CONTROL: a wrapper whose closure never calls its parameter runs nothing it was handed, so calling the labelled export does not reach secret", + "run": ["impact", "secret", "--tests"], + "avoid": ["named.test.js"]}]} diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/index.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/index.js new file mode 100644 index 000000000..0ca257ee5 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/index.js @@ -0,0 +1,3 @@ +import op3 from './op3.js'; +import op4 from './op4.js'; +export default { op3, op4 }; diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/label.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/label.js new file mode 100644 index 000000000..f1fd17f24 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/label.js @@ -0,0 +1,3 @@ +export default function label(fn) { + return function labelled() { return fn.name; }; +} diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/named.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/named.js new file mode 100644 index 000000000..2fda0a4cc --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/named.js @@ -0,0 +1,3 @@ +import label from './label.js'; +function secret() { return 42; } +export default label(secret); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op0.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op0.js new file mode 100644 index 000000000..512a36e3f --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op0.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op0Impl(coll, cb) { return cb(null, coll.length + 0); } +export default promisable(op0Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op1.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op1.js new file mode 100644 index 000000000..64b59b8bd --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op1.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op1Impl(coll, cb) { return cb(null, coll.length + 1); } +export default promisable(op1Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op10.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op10.js new file mode 100644 index 000000000..942919bfc --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op10.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op10Impl(coll, cb) { return cb(null, coll.length + 10); } +export default promisable(op10Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op11.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op11.js new file mode 100644 index 000000000..fd06b9958 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op11.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op11Impl(coll, cb) { return cb(null, coll.length + 11); } +export default promisable(op11Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op12.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op12.js new file mode 100644 index 000000000..0e1cc8ec1 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op12.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op12Impl(coll, cb) { return cb(null, coll.length + 12); } +export default promisable(op12Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op13.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op13.js new file mode 100644 index 000000000..7333efb64 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op13.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op13Impl(coll, cb) { return cb(null, coll.length + 13); } +export default promisable(op13Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op14.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op14.js new file mode 100644 index 000000000..2151714cb --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op14.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op14Impl(coll, cb) { return cb(null, coll.length + 14); } +export default promisable(op14Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op15.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op15.js new file mode 100644 index 000000000..39b600b4e --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op15.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op15Impl(coll, cb) { return cb(null, coll.length + 15); } +export default promisable(op15Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op16.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op16.js new file mode 100644 index 000000000..4b2196158 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op16.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op16Impl(coll, cb) { return cb(null, coll.length + 16); } +export default promisable(op16Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op17.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op17.js new file mode 100644 index 000000000..3f40d0433 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op17.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op17Impl(coll, cb) { return cb(null, coll.length + 17); } +export default promisable(op17Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op18.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op18.js new file mode 100644 index 000000000..2844d30bc --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op18.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op18Impl(coll, cb) { return cb(null, coll.length + 18); } +export default promisable(op18Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op19.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op19.js new file mode 100644 index 000000000..5ae0a6cbf --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op19.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op19Impl(coll, cb) { return cb(null, coll.length + 19); } +export default promisable(op19Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op2.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op2.js new file mode 100644 index 000000000..6332264f9 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op2.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op2Impl(coll, cb) { return cb(null, coll.length + 2); } +export default promisable(op2Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op20.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op20.js new file mode 100644 index 000000000..f69644bcb --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op20.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op20Impl(coll, cb) { return cb(null, coll.length + 20); } +export default promisable(op20Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op21.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op21.js new file mode 100644 index 000000000..bfed47cfe --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op21.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op21Impl(coll, cb) { return cb(null, coll.length + 21); } +export default promisable(op21Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op22.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op22.js new file mode 100644 index 000000000..b7b24386c --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op22.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op22Impl(coll, cb) { return cb(null, coll.length + 22); } +export default promisable(op22Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op23.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op23.js new file mode 100644 index 000000000..bf228d69b --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op23.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op23Impl(coll, cb) { return cb(null, coll.length + 23); } +export default promisable(op23Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op3.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op3.js new file mode 100644 index 000000000..19c2ac869 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op3.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op3Impl(coll, cb) { return cb(null, coll.length + 3); } +export default promisable(op3Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op4.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op4.js new file mode 100644 index 000000000..f38f30c4e --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op4.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op4Impl(coll, cb) { return cb(null, coll.length + 4); } +export default promisable(op4Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op5.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op5.js new file mode 100644 index 000000000..1898ad429 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op5.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op5Impl(coll, cb) { return cb(null, coll.length + 5); } +export default promisable(op5Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op6.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op6.js new file mode 100644 index 000000000..820234a43 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op6.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op6Impl(coll, cb) { return cb(null, coll.length + 6); } +export default promisable(op6Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op7.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op7.js new file mode 100644 index 000000000..53e4e1a68 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op7.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op7Impl(coll, cb) { return cb(null, coll.length + 7); } +export default promisable(op7Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op8.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op8.js new file mode 100644 index 000000000..873712ec6 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op8.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op8Impl(coll, cb) { return cb(null, coll.length + 8); } +export default promisable(op8Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op9.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op9.js new file mode 100644 index 000000000..f7dfa9073 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/op9.js @@ -0,0 +1,3 @@ +import promisable from './promisable.js'; +function op9Impl(coll, cb) { return cb(null, coll.length + 9); } +export default promisable(op9Impl, 2); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/promisable.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/promisable.js new file mode 100644 index 000000000..857332140 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/lib/promisable.js @@ -0,0 +1,12 @@ +export default function promisable(fn, arity) { + function run(...args) { + if (typeof args[arity - 1] === 'function') { + return fn.apply(this, args); + } + return new Promise((resolve) => { + args[arity - 1] = (err, value) => resolve(value); + fn.apply(this, args); + }); + } + return run; +} diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/package.json b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/package.json new file mode 100644 index 000000000..5a4a9bf6b --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/package.json @@ -0,0 +1 @@ +{"name": "wrapkit", "type": "module"} diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/named.test.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/named.test.js new file mode 100644 index 000000000..82c773718 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/named.test.js @@ -0,0 +1,2 @@ +import named from '../lib/named.js'; +it('named', () => { named(); }); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op3.test.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op3.test.js new file mode 100644 index 000000000..9ef95bfc0 --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op3.test.js @@ -0,0 +1,2 @@ +import op3 from '../lib/op3.js'; +it('op3', (done) => { op3([1], done); }); diff --git a/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op4.test.js b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op4.test.js new file mode 100644 index 000000000..c82a1032b --- /dev/null +++ b/tests/cases/javascript/call-through-a-wrapper-runs-its-argument/src/test/op4.test.js @@ -0,0 +1,2 @@ +import lib from '../lib/index.js'; +it('op4', (done) => { lib.op4([1, 2], done); }); From 9d52b70c8238231ca030295206d891d8146f995c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:35 -0700 Subject: [PATCH 08/40] fix(parser/python): an import of a module shipped with a .pyi beside it resolves to the .py The project linker keys modules by qualified name, and a package that ships `impl.pyi` beside `impl.py` declares that name twice. The later file won the map, and `.pyi` sorts after `.py`, so `from .impl import *` and `import pkg` resolved to the stub. The engine refuses a stub declaration as an edge target, so every call into such a package ended at the library boundary and its tests reached nothing in it. A stub no longer replaces a non-stub module of the same name. A stub with no `.py` beside it is still the import's target. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../parsers/python/extractors/python-resolution-linker.ts | 8 ++++++++ parser/src/workflows/python/python-project-analyzer.ts | 1 + .../stub-beside-source-resolves-to-source/case.json | 8 ++++++++ .../stub-beside-source-resolves-to-source/pkg/__init__.py | 1 + .../pkg/__init__.pyi | 1 + .../stub-beside-source-resolves-to-source/pkg/impl.py | 5 +++++ .../stub-beside-source-resolves-to-source/pkg/impl.pyi | 1 + .../stub-beside-source-resolves-to-source/pyproject.toml | 3 +++ .../tests/__init__.py | 0 .../tests/test_chunk.py | 5 +++++ 10 files changed, 33 insertions(+) create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/case.json create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c54857..71d448e58 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a5..0ec56d2bf 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 000000000..97a74a7fb --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 000000000..66c63213a --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 000000000..475cf4eac --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 000000000..79628415b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 000000000..3f70ad893 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 000000000..c311e64bc --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 000000000..c459b14b6 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]] From 221e3b64480ddbcc22a99108d8c475665dac5d91 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:17:29 -0700 Subject: [PATCH 09/40] fix(python): type a test parameter by the fixture the runner hands it pytest fills a test's parameter with whatever the fixture of that name returned or yielded, and no call site spells that call, so the parameter stayed untyped: every method called on it was ambiguous_unknown, and everything derived from it (a local assigned from one of those calls, a with block) went with it. Across ten public suites 1,529 such calls were unresolved; a suite whose fixtures are annotated already resolved its own. value-flow.dl now treats the runner's call as one more argument reaching a parameter: param_arg_type from the fixture's value type (its return, its yield, or its declared return). The fixture serving a parameter comes from a syntax-only copy of the runner's lookup (class, own module, nearest conftest): the existing py_fixture_injection reads module_member_method for star-imported fixtures, which is resolution, and its nearest-conftest MAX would then be a cyclic aggregate. Star-imported and pytest_plugins fixtures still reach their tests through the injection edge; they only stay untyped. Measured with a behavioural oracle (break each of 600 sampled functions, record which test files fail) on ten held-in repositories: impact --tests recall 0.668 -> 0.698, every-failing-file-selected 46.7% -> 50.5%, precision unchanged; the three repositories carrying the pattern move, the seven without it are byte-identical. Engine suite 43/43 with identical case results; query cases 310/310. New case fixture-value-types-the-parameter fails on the base engine on its derived-local check. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/framework-behavior/dispatch.dl | 47 +++++++++++++++++++ graph/python/engine/resolution/value-flow.dl | 15 ++++++ .../app/__init__.py | 0 .../app/core.py | 26 ++++++++++ .../case.json | 17 +++++++ .../tests/conftest.py | 15 ++++++ .../tests/test_ledger.py | 2 + .../tests/test_tx.py | 3 ++ 8 files changed, 125 insertions(+) create mode 100644 tests/cases/python/fixture-value-types-the-parameter/app/__init__.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/app/core.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/case.json create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index 0ede5dbfa..c2c8dc38d 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -412,6 +412,53 @@ py_fixture_requested(m) :- py_fixture_injection(_, m, _). framework_edge(from, to, "fixture_injection", name, "by_name") :- py_fixture_injection(from, to, name). +// ── 2a. THE SAME LOOKUP, FROM SYNTAX ALONE ────────────────────────────────────── +// resolution/value-flow.dl types a parameter by the fixture serving it, so it needs the +// runner's lookup INSIDE the resolution fixpoint. py_fixture_injection cannot be read +// there: a fixture a conftest star-imports is found through module_member_method, which +// is resolution, and the nearest-conftest MAX over it would then be a cyclic aggregate +// souffle refuses to stratify. This chain keeps the runner's order (class, own module, +// nearest conftest by its declaring file) and leaves out the two clauses that need +// resolution — a star-imported fixture and a pytest_plugins one. Those still reach their +// tests through the injection edge; they only go untyped. +.decl py_fixs_in_file(fix:symbol, file:symbol) +py_fixs_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +.decl py_fixs_same_module(req:symbol, name:symbol, fix:symbol) +py_fixs_same_module(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixs_in_file(fix, p). +.decl py_fixs_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) +py_fixs_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + py_fixs_in_file(fix, cp), py_fixture_scope_file(c), + strlen(cp) >= strlen(c), substr(cp, strlen(cp) - strlen(c), strlen(c)) = c, + d = substr(cp, 0, strlen(cp) - strlen(c)), + method_file("client", p, req), + strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). +.decl py_fixs_nearest(req:symbol, name:symbol, depth:number) +py_fixs_nearest(req, name, m) :- py_fixs_conftest_cand(req, name, _, _), + m = max dl : { py_fixs_conftest_cand(req, name, _, dl) }. +.decl py_fixs_injection(from:symbol, to:symbol, name:symbol) +py_fixs_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_same_module(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_conftest_cand(req, name, fix, dl), + !py_fixture_class_shadowed(req, name), !py_fixs_same_module(req, name, _), + py_fixs_nearest(req, name, dl). + +// ── 2b. FIXTURE VALUE (what the runner hands the parameter) ──────────────────── +// py_fixture_value_type(Fixture, Type) — the value a fixture hands over: its return or +// yield value's type, or its declared return. Consumed by resolution/value-flow.dl, where +// the runner's call `test(fixture_value)` is one more argument reaching a parameter. +.decl py_fixture_value_type(fix:symbol, t:symbol) +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type("client", e, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_declared_return_type("client", fix, d), declared_dispatch("client", d, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + expr_node("client", "YIELD", _, _, y), expr_ultimate_method("client", y, fix), + expr_parent("client", y, "YIELD_VALUE", _, v), expr_type("client", v, t). + // ───────────────────────────────────────────────────────────────────────────── // 3. URL DISPATCH (route table -> view) // ───────────────────────────────────────────────────────────────────────────── diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 14ef74b58..69fb4ae45 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -97,6 +97,21 @@ param_arg_type(ph, t) :- param_decl("client", kw, _, _, m, ph), expr_type("client", a, t). +// ── a parameter the TEST RUNNER fills ───────────────────────────────────────── +// pytest calls `test_commit(session=)`: the call is the runner's, +// so no call site spells it, but it is an argument reaching a parameter all the same, and +// the parameter is typed exactly as one passed at a call site would be. Which fixture +// serves the parameter is framework-behavior/dispatch.dl's py_fixs_injection (2a), the +// runner's own lookup (same class, same module, nearest conftest, a pytest_plugins +// module), so this is as deterministic as a name the LEGB walk resolves. +// MEASURED across ten public suites: 1,529 method calls on such parameters were +// ambiguous_unknown, and everything derived from them (`tx = session.begin(); tx.commit()`) +// with them; a suite whose fixtures carry annotations resolved its own. +param_arg_type(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_type(fix, t). + // ── param_arg_method(ParamHash, MethodHash) — a CALLABLE reaching a parameter ── // `Delegator(target)` puts the FUNCTION `target` into the parameter `fn`. Java would // need a functional interface for this; in Python it is an ordinary assignment. diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py b/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/core.py b/tests/cases/python/fixture-value-types-the-parameter/app/core.py new file mode 100644 index 000000000..687b3245e --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/app/core.py @@ -0,0 +1,26 @@ +class Session: + def __init__(self, name): + self.name = name + + def begin(self): + return Transaction(self) + + def close(self): + return None + + +class Transaction: + def __init__(self, session): + self.session = session + + def commit(self): + return validate(self.session.name) + + +def validate(name): + return bool(name) + + +class Ledger: + def post(self, amount): + return amount diff --git a/tests/cases/python/fixture-value-types-the-parameter/case.json b/tests/cases/python/fixture-value-types-the-parameter/case.json new file mode 100644 index 000000000..828a6f458 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a test parameter the runner fills from a yield fixture is typed by what the fixture yields, so a method called on it resolves and the test reaches the change", + "run": ["impact", "Session.begin", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "the type carries through a local derived from the parameter (`tx = session.begin(); tx.commit()`), two calls below the fixture", + "run": ["impact", "validate", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "a return fixture types its parameter the same way", + "run": ["impact", "Ledger.post", "--tests"], + "want": ["test_ledger.py"], + "avoid": ["test_tx.py"]}, + {"why": "CONTROL: a method no test calls on a fixture value reaches no test file through it", + "run": ["impact", "Session.close", "--tests"], + "avoid": ["test_ledger.py"]}]} diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py new file mode 100644 index 000000000..12d7146d6 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from app.core import Ledger, Session + + +@pytest.fixture +def session(): + s = Session("db") + yield s + s.close() + + +@pytest.fixture +def ledger(): + return Ledger() diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py new file mode 100644 index 000000000..543029006 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py @@ -0,0 +1,2 @@ +def test_post(ledger): + assert ledger.post(3) == 3 diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py new file mode 100644 index 000000000..54cfaa2e0 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py @@ -0,0 +1,3 @@ +def test_commit(session): + tx = session.begin() + assert tx.commit() From 6bc6ce614c6340c0a0a82e85c94f3a28889d92dd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:34:49 -0700 Subject: [PATCH 10/40] fix(python): a decorator returning update_wrapper(wrapper, f) or cast(T, wrapper) returns wrapper functools.update_wrapper(wrapper, wrapped) hands back `wrapper`, and typing.cast(T, x) hands back `x`; both are documented, neither module is staged in a client-only run. A decorator written `return update_wrapper(wrapper, f)`, or typed as `return t.cast(F, update_wrapper(wrapper, f))`, was therefore opaque: every method it decorates was decorator_replaced_target and no call through the attribute reached anything. That shape occurs in five of sixteen public repositories measured. A catalogue, py_returns_arg(DottedName, Position) in builtins.dl, lists the two functions and the argument each returns; call_chain.dl follows such calls (to any depth) to the returned name, matched through the import that binds the callee: a module import, its alias (`import typing as t` is MODULE_IMPORT_ALIAS, which the copy.copy rule beside it also misses), or a from-import. On ten held-in repositories, path's found rate on runtime-proven chains goes 0.720 -> 0.744 (the repository with the shape: 0.52 -> 0.76); test selection and the other verbs unchanged. Engine suite 43/43 with identical case results; query cases 313/313; case decorator-returning-update-wrapper fails on the base engine on both decorator checks. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/call_chain.dl | 25 +++++++++++ graph/python/engine/resolution/builtins.dl | 12 +++++ graph/python/souffle/decls_all.dl | 3 ++ .../case.json | 13 ++++++ .../pkg/__init__.py | 0 .../pkg/scaffold.py | 45 +++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 tests/cases/python/decorator-returning-update-wrapper/case.json create mode 100644 tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py create mode 100644 tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index b1c334c56..c6bf5e07e 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -156,6 +156,31 @@ method_returns_method("client", m, r) :- expr_call_candidate(site, g), method_returns_method("client", g, r). +// ── a call that hands back one of its arguments (py_returns_arg, resolution/builtins.dl) ── +// Matched through the import that binds the callee, never by the bare name: +// `functools.update_wrapper(...)` / `t.cast(...)` where the receiver is the name an `import` +// bound, or `update_wrapper(...)` bound by `from functools import update_wrapper`. A local +// function that happens to share the name does neither. +py_passthrough_arg(site, pos) :- + call_name(site, fn), call_receiver_object(site, obj), + expr_binding("client", rb, ctx, obj), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", k, mod, _, i), + (k = "MODULE_IMPORT" ; k = "MODULE_IMPORT_ALIAS"), // `import typing as t` is the alias kind + py_returns_arg(path, pos), cat(mod, cat(".", fn)) = path. +py_passthrough_arg(site, pos) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", rb, ctx, callee), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", _, path, _, i), + py_returns_arg(path, pos). +// the expression a value really is, through any number of such calls +py_passthrough_root(e, e) :- method_return_value_expr("client", _, e). +py_passthrough_root(e, x) :- + py_passthrough_root(e, c), call_of_expr(c, site), py_passthrough_arg(site, pos), + call_arg(site, pos, x). +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), py_passthrough_root(e, x), x != e, + expr_names_method("client", x, r). + // ── decorator_hits_lib(SiteKey, LibMethodHash) ─────────────────────────────── // `@abstractmethod` names `abc.abstractmethod`, which HAS Python source and is in the // staged stdlib IR — it is a library boundary, not a blind spot. A BARE decorator has no diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index ca1e14d1e..02da01634 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -182,6 +182,18 @@ py_copy_module("copy"). py_copy_function("copy"). py_copy_function("deepcopy"). +// ── py_returns_arg(DottedName, Position) — a library call that hands back an argument ── +// Each returns, unchanged, the argument at Position (documented behaviour, not inference): +// functools.update_wrapper(wrapper, wrapped) -> wrapper (copies __name__/__doc__ onto it) +// typing.cast(T, value) -> value (a no-op at run time) +// A decorator written `return update_wrapper(wrapper, f)`, or the typed spelling +// `return t.cast(F, update_wrapper(wrapper, f))`, is therefore the same shape as one +// returning `wrapper` under @functools.wraps. Without these every method it decorates was +// decorator_replaced_target, and no call through the attribute reached anything +// (call-edge-generation/call_chain.dl). Neither module is staged in a client-only run. +py_returns_arg("functools.update_wrapper", "0"). +py_returns_arg("typing.cast", "1"). + // ── py_builtin_dynamic(Name) — the escape hatches ──────────────────────────── // These do not merely lack Python source; they make the PROGRAM unanalysable at that // point. A call to getattr/eval/exec means the engine cannot know what runs, and the diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index d6b069c6a..a1ab95069 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -313,6 +313,9 @@ .decl py_path_join_function(c0:symbol) .decl py_path_join_operator(c0:symbol) .decl py_copy_function(c0:symbol) +.decl py_returns_arg(c0:symbol,c1:symbol) +.decl py_passthrough_arg(c0:symbol,c1:symbol) +.decl py_passthrough_root(c0:symbol,c1:symbol) .decl dict_lookup_method(c0:symbol) .decl builtin_target(c0:symbol,c1:symbol) .decl builtin_object_init_target(c0:symbol) diff --git a/tests/cases/python/decorator-returning-update-wrapper/case.json b/tests/cases/python/decorator-returning-update-wrapper/case.json new file mode 100644 index 000000000..58328af46 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a decorator returning functools.update_wrapper(wrapper, f) hands back `wrapper`: a call to the decorated method resolves to the wrapper, and the caller reaches the method through it rather than by name", + "run": ["impact", "Scaffold.other"], + "want": ["2 hop(s) App.use"], + "avoid": ["[by name] App.use"]}, + {"why": "the typed spelling `return t.cast(F, update_wrapper(wrapper, f))`, typing imported as an alias, is the same shape", + "run": ["impact", "Scaffold.route"], + "want": ["2 hop(s) App.get"], + "avoid": ["[by name] App.get"]}, + {"why": "CONTROL: an undecorated method keeps its resolved caller", + "run": ["impact", "Scaffold.add"], + "want": ["[resolved] Scaffold.route"]}]} diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py new file mode 100644 index 000000000..08c14f156 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py @@ -0,0 +1,45 @@ +import typing as t +from functools import update_wrapper + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def setupmethod(f: F) -> F: + f_name = f.__name__ + + def wrapper_func(self, *args, **kwargs): + self._check(f_name) + return f(self, *args, **kwargs) + + return t.cast(F, update_wrapper(wrapper_func, f)) + + +def plain(f): + def inner(self, *args): + return f(self, *args) + + return update_wrapper(inner, f) + + +class Scaffold: + def _check(self, n): + return n + + @setupmethod + def route(self, rule): + return self.add(rule) + + @plain + def other(self, x): + return x + + def add(self, rule): + return rule + + +class App(Scaffold): + def get(self, rule): + return self.route(rule) + + def use(self): + return self.other(1) From 6aae52715505dcbea41d951a4654e53ab00251a5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:43:12 -0700 Subject: [PATCH 11/40] fix(impact): a call under `if __name__ == "__main__":` is not taken by the import walk impact's `at import` rung names every test file importing a module whose BODY reaches the change through calls that run, because a module that raises while being imported fails every file importing it. A module body's calls inside `if __name__ == "__main__":` are real edges, but they run only when the file is executed as a script, never on import. Walking them named every importer of a module that ends in a demo block. The fact export now records guard_only(module, callee) where every call site the module body has to that callee lies inside a top-level main guard, and up_running does not take those edges. Ordinary reachability is unchanged; only the import hop is. Measured with the mutation oracle on ten held-in repositories, the at-import rung was the second-noisiest (precision 0.16 over 720 selected test files), and 637 of those 720 were for targets that never run on import. On the repository carrying the shape its at-import selections drop 713 -> 305 and test-selection precision 0.335 -> 0.373 with recall unchanged; the control repository whose at-import selections were all correct is unchanged. Query cases 315/315 python, 264/264 typescript, 333/333 java; case main-guard-does-not-run-on-import fails on the base engine on the guarded check. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 31 ++++++++++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 4 ++- .../case.json | 9 ++++++ .../pkg/__init__.py | 0 .../pkg/demo.py | 13 ++++++++ .../tests/test_demo.py | 5 +++ .../tests/test_other.py | 5 +++ 7 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/case.json create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a8dc195f..df2fcce2e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1321,7 +1321,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '63' # 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '64' # 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1530,6 +1530,35 @@ class Impact: W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + self.protected_name_stubs()) + # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run + # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the + # import walk (dl/impact.dl up_running): otherwise a module ending in a demo block reads as breaking at import for + # every file that imports it. + guarded = {} + def guard_ranges(f): + if f not in guarded: + rs = [] + try: + import ast as _ast + for n in _ast.parse('\n'.join(self.lines(f))).body: + t = n.test if isinstance(n, _ast.If) else None + if isinstance(t, _ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], _ast.Eq): + sides = [t.left, t.comparators[0]] + if any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ + any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides): + rs.append((n.lineno, n.end_lineno)) + except (SyntaxError, ValueError): + pass + guarded[f] = rs + return guarded[f] + sites_of = collections.defaultdict(list) + for r in g.q("SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + "WHERE e.callee_method_id IS NOT NULL"): + sy = g.sym.get(r['caller_id']) or {} + if sy.get('kind') == 'module' and (r['file_path'] or '').endswith('.py'): + sites_of[(r['caller_id'], r['callee_method_id'])].append((g.site_file(r['file_path']), r['start_line'] or 0)) + W('guard_only', sorted((a, b) for (a, b), ss in sites_of.items() + if all(any(lo <= ln <= hi for lo, hi in guard_ranges(f)) for f, ln in ss))) # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) # records its edges on the statement's first line, while the handler is named on a later one W('handoff_at', sorted({(r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']), l) for r in g.q( diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f8d70e364..69752becc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -937,7 +937,9 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// a module body's call made only under `if __name__ == "__main__":` runs when the file is a script, never on import +.decl guard_only(a:symbol, b:symbol) .input guard_only +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), !guard_only(a, b), d < 40. // the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f .decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. diff --git a/tests/cases/python/main-guard-does-not-run-on-import/case.json b/tests/cases/python/main-guard-does-not-run-on-import/case.json new file mode 100644 index 000000000..f50525ba4 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", + "run": ["impact", "show", "--tests"], + "want": ["0 of 2 test method(s) reach the change"], + "avoid": ["at import"]}, + {"why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", + "run": ["impact", "render", "--tests"], + "want": ["test_other.", "1 at import"]}]} diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py new file mode 100644 index 000000000..1bb817e71 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py @@ -0,0 +1,13 @@ +def render(x): + return str(x) + + +def show(): + return render(1) + + +TABLE = render("header") + + +if __name__ == "__main__": + print(show()) diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py new file mode 100644 index 000000000..1a974c9c8 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py @@ -0,0 +1,5 @@ +from pkg import demo + + +def test_render(): + assert demo.render(2) == "2" diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py new file mode 100644 index 000000000..5e8f6973c --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py @@ -0,0 +1,5 @@ +import pkg.demo + + +def test_nothing(): + assert True From bfb70899ee44be952917b3f9230c8e9b8661c798 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:48:45 -0700 Subject: [PATCH 12/40] fix(impact): a conjunctive main guard (`cond and __name__ == "__main__"`) is a guard too `if sys.platform != "win32" and __name__ == "__main__":` is false on import whatever the other conjunct is, so the block cannot run then; an `or` could, and is not read as a guard. The first guard rule missed the conjunction, which on the repository carrying the shape was where almost all of the remaining false at-import routes came from. There: at-import selections 305 -> 55, of which 53 fail under the mutation oracle; test- selection precision 0.373 -> 0.446. Recall 0.810 -> 0.794: a few failing files had been selected only through an at-import route that cannot run, right by accident; their real route is one the graph does not see. Control repository unchanged. IMPACT_VERSION 65, so fact caches written under 64 are not reused. Case extended with the conjunctive spelling; query cases green. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 16 +++--- .../case.json | 53 ++++++++++++++++--- .../pkg/demo.py | 8 +++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index df2fcce2e..d840e495b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1321,7 +1321,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '64' # 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '65' # 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1540,13 +1540,17 @@ class Impact: rs = [] try: import ast as _ast - for n in _ast.parse('\n'.join(self.lines(f))).body: - t = n.test if isinstance(n, _ast.If) else None + def is_main(t): if isinstance(t, _ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], _ast.Eq): sides = [t.left, t.comparators[0]] - if any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ - any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides): - rs.append((n.lineno, n.end_lineno)) + return any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ + any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides) + # `cond and __name__ == "__main__"`: a conjunct false on import keeps the block from running; + # an `or` would not, so it is not read as a guard + return isinstance(t, _ast.BoolOp) and isinstance(t.op, _ast.And) and any(is_main(v) for v in t.values) + for n in _ast.parse('\n'.join(self.lines(f))).body: + if isinstance(n, _ast.If) and is_main(n.test): + rs.append((n.lineno, n.end_lineno)) except (SyntaxError, ValueError): pass guarded[f] = rs diff --git a/tests/cases/python/main-guard-does-not-run-on-import/case.json b/tests/cases/python/main-guard-does-not-run-on-import/case.json index f50525ba4..4d1ef066a 100644 --- a/tests/cases/python/main-guard-does-not-run-on-import/case.json +++ b/tests/cases/python/main-guard-does-not-run-on-import/case.json @@ -1,9 +1,46 @@ -{"lang": "python", "src": ".", +{ + "lang": "python", + "src": ".", "checks": [ - {"why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", - "run": ["impact", "show", "--tests"], - "want": ["0 of 2 test method(s) reach the change"], - "avoid": ["at import"]}, - {"why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", - "run": ["impact", "render", "--tests"], - "want": ["test_other.", "1 at import"]}]} + { + "why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", + "run": [ + "impact", + "show", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "a guard written as a conjunction (`cond and __name__ == \"__main__\"`) is still false on import", + "run": [ + "impact", + "banner", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_other.", + "1 at import" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py index 1bb817e71..96beb1b60 100644 --- a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py +++ b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py @@ -11,3 +11,11 @@ def show(): if __name__ == "__main__": print(show()) + + +def banner(): + return render("b") + + +if render and __name__ == "__main__": + banner() From cafd6d2931f194a7420eb1a6048c2f3dda61132b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:00:15 -0700 Subject: [PATCH 13/40] fix(impact): an on-demand protocol member is reached from test code that builds the object, not from every constructor impact takes a hop from a protocol member (a dunder no call site names) to whoever constructs its type: you do not build a context manager without entering it. That holds for __enter__/__exit__, __bool__, __hash__, __call__ (precision 0.61-0.80 under the mutation oracle), and not for showing a value (__repr__, __str__, __format__), pickling or copying it (__getstate__, __setstate__, __reduce__, ...) or deleting from it (__delitem__, __delattr__, __del__): those run only when some code asks, and a constructor deep inside other code says nothing about that. Measured on ten held-in repositories, __repr__ alone was 124 selected test files at precision 0.05, across eight of them. For those members the hop is now taken only from test code that constructs the type: a test that builds the object is the one that asks for its repr. Dropping the hop outright removed 403 false files but left 13 targets with a failing test and an empty answer; this form keeps every answer non-empty. Ten held-in repositories (this commit with the two main-guard ones): test-selection precision 0.408 -> 0.454, recall 0.698 -> 0.694, empty answers unchanged at 84/535. Case on-demand-dunder-hop-from-tests fails on the base engine on the __repr__ check; its control (__eq__ keeps every constructor) passes on both. Query cases 318/318 python, 264/264 typescript, 333/333 java. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/dl/impact.dl | 14 +++++++++++++- .../on-demand-dunder-hop-from-tests/case.json | 9 +++++++++ .../pkg/__init__.py | 0 .../pkg/model.py | 17 +++++++++++++++++ .../tests/test_point.py | 5 +++++ .../tests/test_use.py | 5 +++++ 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/case.json create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 69752becc..e831a6a79 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1094,6 +1094,16 @@ direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", // twice, which is the shape that made an override count as both contract and reached. .decl protocol_member(t:symbol, m:symbol) protocol_member(t, m) :- member(t, m, n, _), match("__.*__", n), n != "__init__". +// Showing a value (`__repr__`, `__str__`, `__format__`), pickling or copying it (`__getstate__`, `__setstate__`, +// `__reduce__`, ...) and deleting from it (`__delitem__`, `__delattr__`, `__del__`) happen only when some code asks, +// so a constructor deep inside other code says nothing about whether they run, and taking the hop from every one of +// them named every test above it. A TEST that builds the object is the one that asks: for these the hop is taken +// from test code only. +.decl protocol_on_demand(n:symbol) +protocol_on_demand("__repr__"). protocol_on_demand("__str__"). protocol_on_demand("__format__"). +protocol_on_demand("__getstate__"). protocol_on_demand("__setstate__"). protocol_on_demand("__getnewargs__"). +protocol_on_demand("__getnewargs_ex__"). protocol_on_demand("__reduce__"). protocol_on_demand("__reduce_ex__"). +protocol_on_demand("__delitem__"). protocol_on_demand("__delattr__"). protocol_on_demand("__del__"). .decl constructs(c:symbol, t:symbol) constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // The hop is taken only where the CHANGE ITSELF is a protocol member, not wherever one turns up in the closure. @@ -1102,7 +1112,9 @@ constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // that cost 115 false pairs for 46 true ones. Narrowed to the seed it keeps what it was written for — a // declaration with NO call site by design, whose callers are whoever built the object — and drops the rest. .decl protocol_hop(q:symbol, c:symbol, m:symbol) -protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), !protocol_on_demand(n), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), protocol_on_demand(n), constructs(c, t), c != m, + decl_file(c, f), is_test_file(f). up(q, c, 1) :- protocol_hop(q, c, _). parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, protocol_hop(q, a, b). // WHAT RUNS AT IMPORT. `settings = Settings()` at the top level of a module is executed by the import statement, diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/case.json b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json new file mode 100644 index 000000000..8ca71978b --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "showing a value is on demand: a test that builds the object is taken for `__repr__`, a constructor deep in the source is not", + "run": ["impact", "Point.__repr__", "--tests"], + "want": ["test_point.py"], + "avoid": ["test_use.py"]}, + {"why": "CONTROL: a protocol a constructed object does not avoid (`__eq__`) keeps the hop from every constructor", + "run": ["impact", "Point.__eq__", "--tests"], + "want": ["test_use.py", "test_point.py"]}]} diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py new file mode 100644 index 000000000..c37360f86 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py @@ -0,0 +1,17 @@ +class Point: + def __init__(self, x, y): + self.x, self.y = x, y + + def __repr__(self): + return f"Point({self.x}, {self.y})" + + def __eq__(self, other): + return (self.x, self.y) == (other.x, other.y) + + +def make(): + return Point(1, 2) + + +def use(): + return make() diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py new file mode 100644 index 000000000..c0b95782f --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py @@ -0,0 +1,5 @@ +from pkg.model import Point + + +def test_repr(): + assert repr(Point(1, 2)) == "Point(1, 2)" diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py new file mode 100644 index 000000000..17baa6db8 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py @@ -0,0 +1,5 @@ +from pkg.model import use + + +def test_use(): + assert use() is not None From b0dca3c29691e2d6c5b9aa7bcba0331a88b492c3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:23:09 -0700 Subject: [PATCH 14/40] fix(python): `obj.x = v` and `del obj.x` are calls to the property's setter and deleter The engine emitted a PROPERTY_READ edge for reading a @property and nothing for writing or deleting one, though `obj.x = v` runs `@x.setter` and `del obj.x` runs `@x.deleter` exactly as a read runs the getter. A setter therefore had no caller: an impact on it named no test, and everything the setter calls was cut off from whoever assigns. Setters occur in five of sixteen public repositories measured; the torture fixture documented the gap as a known miss. type_property_accessor finds the setter / deleter on the class that wins the name in the receiver's MRO (they share the getter's binding, so mro_lookup's single answer is not enough); property_write_edge keys on the attribute access's STORE / DEL context; the edge is exported as call kind PROPERTY_WRITE (schema vocabulary for Python, the kind TypeScript already uses for its accessors), mapped to the `property` rung. Torture: the CPython oracle confirms the new edge (agree 587 -> 588, missing 47 -> 46, extra unchanged); f43's known-miss marker is removed with its docstring rewritten at the same line count; goldens updated. Engine suite 43/43. Query cases 322/322 python, 264/264 typescript, 333/333 java; case property-setter-is-a-call fails on the base engine on all three write/delete checks. All six commits together, ten held-in repositories: test-selection recall 0.668 -> 0.700, precision 0.403 -> 0.452, impact source-caller recall 0.673 -> 0.686, path found 0.720 -> 0.752, context recall@5 0.789 -> 0.799; no repository regresses beyond 0.003 on any of them. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../engine/call-edge-generation/call_chain.dl | 26 ++++++++++++++++ .../engine/resolution/attribute-lookup.dl | 9 ++++++ graph/python/souffle/decls_all.dl | 2 ++ .../python/torture/client/f43_def_rebind.py | 8 ++--- .../test/python/torture/expected/coverage.txt | 10 +++--- .../python/torture/expected/torture.edges | 1 + .../python/torture/expected/torture.oracle | 2 +- .../skills/axiomcode/scripts/ax_edges.py | 4 +-- .../property-setter-is-a-call/case.json | 16 ++++++++++ .../property-setter-is-a-call/pkg/__init__.py | 0 .../python/property-setter-is-a-call/pkg/m.py | 31 +++++++++++++++++++ .../tests/test_delete.py | 7 +++++ .../tests/test_read.py | 5 +++ .../tests/test_write.py | 7 +++++ 16 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 tests/cases/python/property-setter-is-a-call/case.json create mode 100644 tests/cases/python/property-setter-is-a-call/pkg/__init__.py create mode 100644 tests/cases/python/property-setter-is-a-call/pkg/m.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_delete.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_read.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_write.py diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 2c17b3916..a78cdc699 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. - **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls. -- **python** — PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. +- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. - **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration. ### `call_edges` @@ -531,6 +531,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `DECORATOR_*` | python | Applying an unparenthesised decorator; the suffix is the parser's decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash. | | `METACLASS_CREATION` | python | A class statement invokes its metaclass's `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base's `__init_subclass__`. No written call; the site is the class's type hash. | | `PROPERTY_READ` | python | Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression. | +| `PROPERTY_WRITE` | python | Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression. | | `CONTEXT_MANAGER` | python | `with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression. | | `ITERATION_PROTOCOL` | python | `for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression. | | `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 2e9af3227..ac5e5a3c4 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -667,6 +667,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'DECORATOR_*', languages: P, meaning: 'Applying an unparenthesised decorator; the suffix is the parser\'s decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash.' }, { table: 'call_edges', column: 'kind', value: 'METACLASS_CREATION', languages: P, meaning: 'A class statement invokes its metaclass\'s `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base\'s `__init_subclass__`. No written call; the site is the class\'s type hash.' }, { table: 'call_edges', column: 'kind', value: 'PROPERTY_READ', languages: P, meaning: 'Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression.' }, + { table: 'call_edges', column: 'kind', value: 'PROPERTY_WRITE', languages: P, meaning: 'Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression.' }, { table: 'call_edges', column: 'kind', value: 'CONTEXT_MANAGER', languages: P, meaning: '`with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression.' }, { table: 'call_edges', column: 'kind', value: 'ITERATION_PROTOCOL', languages: P, meaning: '`for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression.' }, { table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' }, @@ -748,7 +749,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' }, { language: 'typescript', table: 'type_instantiated', note: 'Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.' }, { language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' }, - { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, + { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, { language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' }, { language: 'python', table: 'call_edges', note: '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.' }, { language: 'java', table: 'call_edges', note: '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.' }, diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index c6bf5e07e..6c35b6cf3 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -345,6 +345,25 @@ iter_protocol_edge(src, caller, m) :- expr_type("client", src, t), iter_protocol_target(t, m), expr_ultimate_method("client", src, caller). +// ── property_write_edge(WriteExprHash, CallerMethodHash, AccessorMethodHash) ── +// The store and delete halves of the property protocol: `obj.x = v` calls x's setter, +// `del obj.x` its deleter (type_property_accessor, resolution/attribute-lookup.dl). Same +// shape as a read, keyed on the access's own name context. +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "STORE", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_SETTER", m), + expr_ultimate_method("client", e, caller). +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "DEL", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_DELETER", m), + expr_ultimate_method("client", e, caller). + // ── property_read_edge(ReadExprHash, CallerMethodHash, GetterMethodHash) ───── property_read_edge(e, caller, getter) :- expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), @@ -693,6 +712,7 @@ subscript_protocol_edge(sub, caller, m) :- // from the graph rather than being re-tiered. A dropped edge is worse than a mislabelled // one, and the golden caught it. protocol_edge(e, d, caller, m) :- property_read_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- with_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- iter_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- subscript_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). @@ -756,6 +776,12 @@ call_chain_edge(e, caller, "-", getter, "client", cls, "PROPERTY_READ") :- property_read_edge(e, caller, getter), method_decl(_, d, _, _, _, getter), protocol_edge_class(e, d, cls). +// PROPERTY WRITE — `obj.x = v` runs x's setter and `del obj.x` its deleter. Likewise no +// call site, and its own kind so it is never counted as a written call. +call_chain_edge(e, caller, "-", m, "client", cls, "PROPERTY_WRITE") :- + property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(e, d, cls). + // CONTEXT MANAGER — the same shape: an edge with no call site, its own kind so it can // never be mistaken for a written call. call_chain_edge(cm, caller, "-", m, "client", cls, "CONTEXT_MANAGER") :- diff --git a/graph/python/engine/resolution/attribute-lookup.dl b/graph/python/engine/resolution/attribute-lookup.dl index 3c032d139..7b44fe2d6 100644 --- a/graph/python/engine/resolution/attribute-lookup.dl +++ b/graph/python/engine/resolution/attribute-lookup.dl @@ -317,6 +317,15 @@ type_call_target(p, t, m) :- mro_lookup(p, t, "__call__", m). type_property_getter(p, t, n, m) :- mro_lookup(p, t, n, m), method_kind(p, "PROPERTY_GETTER", _, m). +// ── type_property_accessor(Prov, TypeHash, Name, Kind, MethodHash) ─────────── +// `@x.setter` and `@x.deleter` are the other two halves: `obj.x = v` runs the setter and +// `del obj.x` the deleter, and like the getter neither has a call site. They share the +// getter's name and binding, so they are found on the class that WINS the name in the +// receiver's MRO, by their method kind, rather than through mro_lookup's one answer. +type_property_accessor(p, t, n, k, m) :- + mro_winner(p, t, n, c), method_owner(p, c, m), method_decl(p, n, _, _, _, m), + method_kind(p, k, _, m), (k = "PROPERTY_SETTER" ; k = "PROPERTY_DELETER"). + // ── A USER-WRITTEN DATA DESCRIPTOR IS THE SAME PROTOCOL (issue #326) ───────── // `@property` IS a data descriptor; the decorator is sugar over `__get__`/`__set__`. So // the clause above reads one spelling of the protocol and a class-level diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index a1ab95069..4818adea5 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -597,6 +597,8 @@ .decl decorator_hits_lib(c0:symbol,c1:symbol) .decl decorator_hits_builtin(c0:symbol,c1:symbol) .decl property_read_edge(c0:symbol,c1:symbol,c2:symbol) +.decl property_write_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_property_accessor(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl with_protocol_edge(c0:symbol,c1:symbol,c2:symbol) .decl iteration_protocol_sync(c0:symbol) .decl iteration_protocol_of(c0:symbol,c1:symbol) diff --git a/graph/test/python/torture/client/f43_def_rebind.py b/graph/test/python/torture/client/f43_def_rebind.py index 107989af0..3a454e7e6 100644 --- a/graph/test/python/torture/client/f43_def_rebind.py +++ b/graph/test/python/torture/client/f43_def_rebind.py @@ -89,10 +89,10 @@ def call_branched() -> str: def read_property() -> int: """The getter survives — the control that keeps this rule off property pairs. - EXPECT: miss — `h.value = 5` invokes the SETTER, and the engine emits a - PROPERTY_READ edge for a property read and nothing at all for a property WRITE. - That is a pre-existing gap this fixture happens to expose, not something #383 - changed; the getter edge on the next line is what this family is asserting. + `h.value = 5` invokes the SETTER, and the engine now emits a PROPERTY_WRITE + edge for it beside the PROPERTY_READ for the read; both halves of the pair + are live, which is exactly what this control exists to keep true. The getter + edge on the next line is what this family is asserting. """ h = Holder() h.value = 5 diff --git a/graph/test/python/torture/expected/coverage.txt b/graph/test/python/torture/expected/coverage.txt index 06b88e325..daf7e130d 100644 --- a/graph/test/python/torture/expected/coverage.txt +++ b/graph/test/python/torture/expected/coverage.txt @@ -1,4 +1,4 @@ -=== per-family coverage (tier-4, 471 scored sites) === +=== per-family coverage (tier-4, 474 scored sites) === f01 inheritance & MRO links= 10 found= 10 (100.0%) missed= 0 wide= 0 WRONG= 0 f02 callables & closures links= 10 found= 9 (90.0%) missed= 1 wide= 0 WRONG= 0 f03 generics links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 @@ -38,16 +38,16 @@ f40 data descriptor links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 f41 class attribute absent links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 f42 f42 links= 6 found= 6 (100.0%) missed= 0 wide= 0 WRONG= 0 - f43 f43 links= 7 found= 7 (100.0%) missed= 0 wide= 1 WRONG= 0 + f43 f43 links= 10 found= 10 (100.0%) missed= 0 wide= 1 WRONG= 0 nestmod.py nestmod.py links= 1 found= 1 (100.0%) missed= 0 wide= 0 WRONG= 0 pkgmod relative imports (subpackage) links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 - TOTAL links=434 found=428 (98.6%) missed= 6 wide=37 WRONG= 0 - recall 428/434 = 98.6% of the links that actually ran + TOTAL links=437 found=431 (98.6%) missed= 6 wide=37 WRONG= 0 + recall 431/437 = 98.6% of the links that actually ran wide 37 a member of a SOUND SET that did not run on this pass WRONG 0 a single target asserted as certain that never ran - EXPECTED-MISS cases: {'FOUND': 20, 'MISSED': 24} (a CONCRETE here means a known blind spot closed) + EXPECTED-MISS cases: {'FOUND': 18, 'MISSED': 23} (a CONCRETE here means a known blind spot closed) --- non-concrete sites --- f02_callables.py:31 MISSED true=[('lib', 'callables.py', 22)] engine=[] diff --git a/graph/test/python/torture/expected/torture.edges b/graph/test/python/torture/expected/torture.edges index 4d85cc1a0..557d3208c 100644 --- a/graph/test/python/torture/expected/torture.edges +++ b/graph/test/python/torture/expected/torture.edges @@ -635,6 +635,7 @@ known_edge PROPERTY_READ f40_data_descriptor.via_data_descriptor -> f40_data_des known_edge PROPERTY_READ f40_data_descriptor.via_delete_descriptor -> f40_data_descriptor.Deletable.__get__ known_edge PROPERTY_READ f40_data_descriptor.via_property -> f40_data_descriptor.Prop.slot known_edge PROPERTY_READ f43_def_rebind.read_property -> f43_def_rebind.Holder.value +known_edge PROPERTY_WRITE f43_def_rebind.read_property -> f43_def_rebind.Holder.value known_edge SELF_CALL f02_callables.HoldsCallables.run -> f02_callables.LocalCallable.__call__ known_edge SELF_CALL f28_await.Registry.into_a_field -> f28_await.Registry.build known_edge SELF_CALL f28_await.Registry.via_self_receiver -> f28_await.Registry.build diff --git a/graph/test/python/torture/expected/torture.oracle b/graph/test/python/torture/expected/torture.oracle index 95fd8d908..4267008ae 100644 --- a/graph/test/python/torture/expected/torture.oracle +++ b/graph/test/python/torture/expected/torture.oracle @@ -1 +1 @@ -oracle=634 engine=624 agree=587 missing=47 extra=37 +oracle=634 engine=625 agree=588 missing=46 extra=37 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index f5283b76a..a12843281 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -12,7 +12,7 @@ + COMPUTED_CALL IIFE_CALL DYNAMIC_IMPORT_CALL DYNAMIC_CODE_CALL TAGGED_TEMPLATE_CALL FUNCTION_CALL_APPLY FUNCTION_CALL_CALL FUNCTION_CALL_BIND python SIMPLE_CALL METHOD_CALL SELF_CALL SUPER_CALL CHAINED_CALL SUBSCRIPT_CALL CONTEXT_MANAGER - PROPERTY_READ METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} + PROPERTY_READ PROPERTY_WRITE METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped event_dispatch runtime_observed (only with a runtime trace) · new property_read property_write @@ -109,7 +109,7 @@ 'DECORATOR_APPLICATION': 'decorator', 'DECORATOR_ATTRIBUTE': 'decorator', 'DECORATOR_BARE': 'decorator', 'DECORATOR_CALL': 'decorator', # an accessor: written as a field, run as a method - 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', + 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', 'PROPERTY_WRITE': 'property', # the language runs it at a block boundary 'CONTEXT_MANAGER': 'with', # run-time code loading diff --git a/tests/cases/python/property-setter-is-a-call/case.json b/tests/cases/python/property-setter-is-a-call/case.json new file mode 100644 index 000000000..ef9c8fbb8 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "assigning `obj.x = v` runs x's @x.setter: a test that assigns reaches a change to the setter, one that only reads does not", + "run": ["impact", "pkg/m.py:10", "--tests"], + "want": ["test_write.py"], + "avoid": ["test_read.py"]}, + {"why": "`del obj.x` runs x's @x.deleter", + "run": ["impact", "pkg/m.py:14", "--tests"], + "want": ["test_delete.py"], + "avoid": ["test_read.py"]}, + {"why": "a setter's own calls are reached through the write too", + "run": ["impact", "check", "--tests"], + "want": ["test_write.py"]}, + {"why": "CONTROL: the getter keeps its readers", + "run": ["impact", "pkg/m.py:6", "--tests"], + "want": ["test_read.py"]}]} diff --git a/tests/cases/python/property-setter-is-a-call/pkg/__init__.py b/tests/cases/python/property-setter-is-a-call/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/property-setter-is-a-call/pkg/m.py b/tests/cases/python/property-setter-is-a-call/pkg/m.py new file mode 100644 index 000000000..49ecf51e5 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/pkg/m.py @@ -0,0 +1,31 @@ +class Box: + def __init__(self): + self._v = 0 + + @property + def value(self): + return self._v + + @value.setter + def value(self, v): + self._v = check(v) + + @value.deleter + def value(self): + self._v = None + + +def check(v): + return v + + +def fill(b: Box): + b.value = 3 + + +def clear(b: Box): + del b.value + + +def read(b: Box): + return b.value diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_delete.py b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py new file mode 100644 index 000000000..fd8cf3034 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_delete(): + b = Box() + del b.value + assert b.value is None diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_read.py b/tests/cases/python/property-setter-is-a-call/tests/test_read.py new file mode 100644 index 000000000..843cb79fd --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_read.py @@ -0,0 +1,5 @@ +from pkg.m import Box + + +def test_read_only(): + assert Box().value == 0 diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_write.py b/tests/cases/python/property-setter-is-a-call/tests/test_write.py new file mode 100644 index 000000000..b88149b71 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_write.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_assign(): + b = Box() + b.value = 4 + assert b.value == 4 From ba6d9ba435bc424a758325d4a1b5256894b7e2e2 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:44:53 -0700 Subject: [PATCH 15/40] fix(python): a local's Optional/union annotation types it, and every operand of a 3+ way union counts Two gaps in "Optional[X] IS X", the rule that types a value annotated Optional[X] or X | None: - It existed for parameters, returns and fields but not for a LOCAL variable's annotation, so `ctx: Optional[Context] = ...` and `x: Foo | None = ...` left every call on the local unresolved while the same annotation on a parameter resolved. - `|` is left-associative: `A | B | None` is `(A | B) | None`, so A and B sit two levels below the annotation, under a nested union, and the depth-1 element rule saw only that nested union (which names no type) and None. Every union of three or more operands lost its members, on parameters, returns, fields and locals alike. binding_declared_nominal gains the Optional/union clause; union_operand walks nested PEP 604 unions and feeds their operands to type_ref_element, resolved and by name. Containers are untouched (union_operand is rooted at an Optional/union kind only). Optional/union locals occur in 15 of 16 public repositories measured (96 written with `|`, 50 with Optional[]/Union[]). Ten held-in repositories, against the previous commit: path found 0.752 -> 0.756, impact source-caller recall 0.686 -> 0.687 (one repository 0.795 -> 0.807); the new callers are a union's dispatch set and are labelled `one of a set`, resolved-caller precision unchanged at 0.979. Engine suite 43/43, torture unchanged; query cases 325/325; case optional-and-union-annotations fails on the base engine on both the local and the three-operand checks. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 9 ++++ graph/python/engine/resolution/annotations.dl | 19 ++++++++ graph/python/souffle/decls_all.dl | 1 + .../optional-and-union-annotations/case.json | 12 +++++ .../pkg/__init__.py | 0 .../optional-and-union-annotations/pkg/m.py | 46 +++++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 tests/cases/python/optional-and-union-annotations/case.json create mode 100644 tests/cases/python/optional-and-union-annotations/pkg/__init__.py create mode 100644 tests/cases/python/optional-and-union-annotations/pkg/m.py diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index d46b6ecac..ed530619c 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -585,6 +585,15 @@ binding_declared_nominal(p, bind, d) :- binding_declared_ref(p, bind, r), type_ref_resolved(p, d, r), !type_is_structural(p, d). +// `x: Optional[Order] = ...` / `x: Order | None = ...` IS an Order (or None) — the rule the +// parameter, return and field annotations already have (resolution/annotations.dl, +// "Optional[X] IS X"), which the local annotation alone was missing: a local written +// with the commonest modern spelling stayed untyped while the same annotation on a +// parameter resolved. +binding_declared_nominal(p, bind, t) :- + type_ref_owner(p, bind, "BINDING", r), + type_ref(p, k, "VARIABLE_ANNOTATION", _, _, r), annotation_optional_kind(k), + type_ref_element(p, r, t), !type_is_structural(p, t). // The FK is resolved on only 14 of 479 variable annotations, so the name-based lookup // beside it is what carries this -- the same two clauses the parameter path uses, with // the same kind restriction. A SUBSCRIPT or a UNION names no single type and is served by diff --git a/graph/python/engine/resolution/annotations.dl b/graph/python/engine/resolution/annotations.dl index 0d671aa95..2b9087a4a 100644 --- a/graph/python/engine/resolution/annotations.dl +++ b/graph/python/engine/resolution/annotations.dl @@ -199,6 +199,25 @@ type_ref_element(p, parentRef, t) :- annotation_owner_module(p, owner, ok, mod), type_name_in_module(p, mod, tn, t). +// ── union_operand(Prov, UnionRefHash, OperandRefHash) — every operand of a union ── +// `|` is LEFT-associative, so `A | B | None` is `(A | B) | None`: A and B are not +// children of the annotation but grandchildren, under a nested UNION_PEP604, and the +// depth-1 clauses above saw only that nested union (which names no type) and None. Every +// rule reading "Optional[X] IS X" therefore lost the members of any union with three or +// more operands, on a parameter, a return, a field or a local alike. +union_operand(p, r, c) :- + type_ref(p, k, _, _, _, r), annotation_optional_kind(k), type_ref_nesting(p, r, _, _, c). +union_operand(p, r, c) :- + union_operand(p, r, u), type_ref(p, "UNION_PEP604", _, _, _, u), type_ref_nesting(p, u, _, _, c). +// the operands BELOW depth 1 (depth 1 is the clauses above), resolved, and by name +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", type_ref_resolved(p, t, c). +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", + type_ref(p, _, "GENERIC_ARGUMENT", tn, _, c), tn != "", + type_ref_owner(p, owner, ok, r), annotation_owner_module(p, owner, ok, mod), + type_name_in_module(p, mod, tn, t). + // ── annotation_owner_module(Prov, OwnerHash, OwnerKind, ModuleHash) ────────── // The module an annotation was written in, whatever kind of declaration owns it. annotation_owner_module(p, ph, "METHOD_PARAM", mod) :- diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4818adea5..6f1fd8647 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -238,6 +238,7 @@ .decl element_lib_type_of(c0:symbol,c1:symbol) .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) +.decl union_operand(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/tests/cases/python/optional-and-union-annotations/case.json b/tests/cases/python/optional-and-union-annotations/case.json new file mode 100644 index 000000000..5747f72b8 --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/case.json @@ -0,0 +1,12 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a local annotated `Optional[Foo]` is a Foo, as the same annotation on a parameter already was", + "run": ["impact", "Foo.go"], + "want": ["[resolved] l_optional"]}, + {"why": "a three-operand PEP 604 union nests left-associatively; every operand counts, on a parameter and on a local", + "run": ["impact", "Bar.go"], + "want": ["p_pipe3", "l_pipe"], + "avoid": ["[by name] p_pipe3", "[by name] l_pipe"]}, + {"why": "CONTROL: a two-operand union and a plain annotation resolve as before", + "run": ["impact", "Foo.go"], + "want": ["[resolved] p_pipe", "[resolved] p_plain"]}]} diff --git a/tests/cases/python/optional-and-union-annotations/pkg/__init__.py b/tests/cases/python/optional-and-union-annotations/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/optional-and-union-annotations/pkg/m.py b/tests/cases/python/optional-and-union-annotations/pkg/m.py new file mode 100644 index 000000000..71acab0a5 --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/pkg/m.py @@ -0,0 +1,46 @@ +import typing as t +from typing import Optional, Union + + +class Foo: + def go(self): + return 1 + + +class Bar: + def go(self): + return 2 + + +def p_plain(x: Foo): + return x.go() + + +def p_optional(x: Optional[Foo]): + return x.go() + + +def p_t_optional(x: t.Optional[Foo]): + return x.go() + + +def p_pipe(x: Foo | None): + return x.go() + + +def p_union(x: Union[Foo, Bar]): + return x.go() + + +def l_optional(y): + z: Optional[Foo] = y + return z.go() + + +def l_pipe(y): + z: Foo | Bar | None = y + return z.go() + + +def p_pipe3(x: Foo | Bar | None): + return x.go() From 78eaedb0e1ba506f30d68e146a23aa8368eb93c5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:14:07 -0700 Subject: [PATCH 16/40] fix(impact): a src-layout module is imported by its package name, and a test file imports the conftests above it The import facts named a Python module only by its path from the repository root, so in a src layout `src/app/core.py` was `src.app.core` and no `import app.core` matched it: a src-layout repository had almost no test-to-source imports, which the import walk (a function that runs while a module is imported breaks every file that imports it) and the loads-the-change filter both read. A module is now also named from its package root, the first directory above it that is not a package. pytest imports every conftest.py from the rootdir down to a test file's directory before the file, so what a conftest imports is imported for each test file beneath it; a test file now imports those conftests. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 26 ++++++++++++++++--- .../src-layout-conftest-import-time/case.json | 11 ++++++++ .../pyproject.toml | 6 +++++ .../src/app/__init__.py | 1 + .../src/app/registry.py | 25 ++++++++++++++++++ .../tests/conftest.py | 8 ++++++ .../tests/test_lookup.py | 5 ++++ .../tests/test_plain.py | 2 ++ 8 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/cases/python/src-layout-conftest-import-time/case.json create mode 100644 tests/cases/python/src-layout-conftest-import-time/pyproject.toml create mode 100644 tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/src/app/registry.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/conftest.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index d840e495b..75338e6d8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -806,13 +806,31 @@ class Impact: byfile = set(files) # a module path as each language spells it -> the file it names. Both spellings of a Python package are # kept (app/settings.py and app/settings/__init__.py), because the import statement cannot tell them apart. + # A module is also named from its package root, the first directory above it that is not itself a package: + # in a src layout `src/app/core.py` is imported as `app.core`, and the repository-relative spelling alone + # matches no import of it. mod = {} for f in files: stem = os.path.splitext(f)[0] - mod.setdefault(stem.replace('/', '.'), f) - if stem.endswith('/__init__'): - mod.setdefault(stem[:-len('/__init__')].replace('/', '.'), f) + names = [stem] + if f.endswith(('.py', '.pyi')): + parts = stem.split('/'); k = len(parts) - 1 + while k > 0 and '/'.join(parts[:k]) + '/__init__.py' in byfile: k -= 1 + if k > 0: names.append('/'.join(parts[k:])) + for n in names: + mod.setdefault(n.replace('/', '.'), f) + if n.endswith('/__init__'): + mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, + # so whatever a conftest imports is imported for each test file beneath it + conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] + for f in files: + b = f.rsplit('/', 1)[-1] + if not (b.endswith('.py') and (b.startswith('test_') or b.endswith('_test.py'))): continue + for c in conftests: + cd = os.path.dirname(c) + if not cd or f.startswith(cd + '/'): out.add((f, c)) for f in files: ext = os.path.splitext(f)[1].lower() if not ext: # a python shebang script (#1376) @@ -1321,7 +1339,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '65' # 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/tests/cases/python/src-layout-conftest-import-time/case.json b/tests/cases/python/src-layout-conftest-import-time/case.json new file mode 100644 index 000000000..2efeb4d36 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a src-layout package is imported by its package name: `from app import lookup` in a test is an import of src/app/__init__.py, so the test file reaches what that import runs", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_lookup.py"]}, + {"why": "pytest imports tests/conftest.py before every test file under it, so a function that runs while the package is imported (normalise, through the module-level @register) breaks a test file that never imports the package itself", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_plain.py"]}, + {"why": "CONTROL: describe runs at no import, so a test file that does not call it is not reached by the import walk", + "run": ["impact", "src/app/registry.py:19", "--tests"], + "avoid": ["test_plain.py"]}]} diff --git a/tests/cases/python/src-layout-conftest-import-time/pyproject.toml b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml new file mode 100644 index 000000000..6d1bd6e73 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "app" +version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = "tests" diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py new file mode 100644 index 000000000..3df798e6a --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py @@ -0,0 +1 @@ +from .registry import register, lookup diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py new file mode 100644 index 000000000..43ce0e0a2 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py @@ -0,0 +1,25 @@ +_TABLE = {} + + +def normalise(name): + return name.strip().lower() + + +def register(name): + def deco(fn): + _TABLE[normalise(name)] = fn + return fn + return deco + + +def lookup(name): + return _TABLE[normalise(name)] + + +def describe(name): + return f"handler {normalise(name)}" + + +@register("Default") +def default_handler(): + return "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py new file mode 100644 index 000000000..aa1440417 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +import app + + +@pytest.fixture +def table(): + return app diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py new file mode 100644 index 000000000..539bd9a10 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py @@ -0,0 +1,5 @@ +from app import lookup + + +def test_lookup(): + assert lookup("default")() == "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py new file mode 100644 index 000000000..2733328fb --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py @@ -0,0 +1,2 @@ +def test_arithmetic(): + assert 1 + 1 == 2 From 9e4379e036497c4bf7c4ac1c89dccf36037a674f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:03 -0700 Subject: [PATCH 17/40] csharp: a name written inside a namespace is read from that namespace `namespace Shop.Tests; using Results;` imports Shop.Results, and `Reflection.Info.Create()` written inside namespace Shop names Shop.Reflection.Info: C# reads the first segment of a using's name, and of a qualified name, from the enclosing namespaces outward. The engine took both as written, so the using imported nothing and every client type named through it read as a library type (`new Failure(..)` was boundary_lib), and the qualified receiver stayed untyped. - module_in_scope: a using whose name is no namespace at all takes the child of the file's namespace chain that does exist (the IR does not say whether the using sits inside the namespace; a name that is no namespace only compiles there). - type_name_cand: a type's qualified name is split at each dot into namespace prefix and dotted tail; the tail is a rank-1 candidate in every file whose namespace chain holds the prefix. Keyed from the declaration side. Case a-namespace-name-is-read-from-its-namespace fails 4/6 on the base (the two controls, a file in an unrelated namespace, pass on both). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../csharp/engine/containment/type-nesting.dl | 24 +++++++++++++++++++ .../engine/resolution/type-resolution.dl | 20 ++++++++++++++++ graph/csharp/souffle/decls_all.dl | 4 +++- .../case.json | 20 ++++++++++++++++ .../src/Audit.cs | 13 ++++++++++ .../src/Elsewhere.cs | 15 ++++++++++++ .../src/Machine.cs | 17 +++++++++++++ .../src/Reflection.cs | 7 ++++++ .../src/Results.cs | 8 +++++++ .../src/Shop.csproj | 1 + 10 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/case.json create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Audit.cs create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Elsewhere.cs create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Machine.cs create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Reflection.cs create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Results.cs create mode 100644 tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Shop.csproj diff --git a/graph/csharp/engine/containment/type-nesting.dl b/graph/csharp/engine/containment/type-nesting.dl index e584db419..8ac8b94d7 100644 --- a/graph/csharp/engine/containment/type-nesting.dl +++ b/graph/csharp/engine/containment/type-nesting.dl @@ -149,3 +149,27 @@ module_in_scope(prov, mod, outer) :- // The GLOBAL namespace ("") is always in scope. module_in_scope(prov, mod, "") :- module_decl(prov, _, _, _, mod). + +// module_ns_chain(Prov, ModuleHash, Namespace) -- the namespaces a name written in this +// file is read from: the file's own namespaces and every dotted prefix of them, as +// text. A prefix that holds no type of its own (`Acme` over `Acme.Shop`) still +// qualifies a name written below it, so this is not ns_encloses, which needs one. +module_ns_chain(prov, mod, ns) :- module_namespace_own(prov, mod, ns), ns != "". +module_ns_chain(prov, mod, p) :- module_namespace_own(prov, mod, inner), ns_prefix(prov, inner, p). + +// ── A USING WRITTEN INSIDE A NAMESPACE ───────────────────────────────────── +// `namespace Shop.Tests; using Results;` imports Shop.Results. C# reads the name in +// a using directive from the namespace the directive sits in, innermost outward, +// exactly as it reads any other name there. Taken as written it names a namespace +// that does not exist, and every type the file names through it reads as a library +// type. +// +// The IR does not record whether a using sits inside the namespace or above it. +// The language decides it instead: a using whose name is no namespace at all only +// compiles inside a namespace that has it as a child. So the relative reading is +// taken only when the name as written names nothing, and only for a child that +// exists -- a file whose namespaces have no such child gains nothing. +module_in_scope(prov, mod, full) :- + using_namespace(prov, mod, n), !ns_exists(prov, n), + module_ns_chain(prov, mod, e), full = cat(e, cat(".", n)), + ns_exists(prov, full). diff --git a/graph/csharp/engine/resolution/type-resolution.dl b/graph/csharp/engine/resolution/type-resolution.dl index fb90ddc4b..1c76e1cba 100644 --- a/graph/csharp/engine/resolution/type-resolution.dl +++ b/graph/csharp/engine/resolution/type-resolution.dl @@ -89,6 +89,26 @@ type_name_cand(prov, mod, qn, ar, gk, 1) :- type_decl(prov, _, qn, ar, _, t), type_group(prov, t, gk), !type_is_top_level(prov, gk). +// ── A QUALIFIED NAME READ FROM ITS NAMESPACE ──────────────────────────────── +// `Reflection.Info.Create(..)` written inside namespace Shop names +// Shop.Reflection.Info: the first segment of a qualified name is a +// simple name, looked up in the enclosing namespaces like any other. Matched only +// in full, such a receiver stays untyped and every call through it unresolved. +// +// Keyed from the DECLARATION side, so nothing is enumerated per written name: each +// type's qualified name is split at each dot into the namespace before it and the +// dotted tail after it, and the tail is a candidate in every file whose namespace +// chain holds that prefix. A tail with no dot is a simple name, which ranks 2-5 +// already answer. +type_qn_tail(prov, qn, p, s) :- + type_decl(prov, _, qn, _, _, _), + i = range(1, strlen(qn)), substr(qn, i, 1) = ".", + p = substr(qn, 0, i), s = substr(qn, i + 1, strlen(qn) - i - 1), + contains(".", s). +type_name_cand(prov, mod, s, ar, gk, 1) :- + type_qn_tail(prov, qn, p, s), module_ns_chain(prov, mod, p), + type_decl(prov, _, qn, ar, _, t), type_group(prov, t, gk). + // ── A CLIENT FILE NAMING A STAGED LIBRARY TYPE ────────────────────────────── // Every clause above is SINGLE-PROVENANCE: it joins a module and a declaration // under one `prov`, so a client module and a library namespace are never brought diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index b1faa46b7..2e4548373 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -65,9 +65,9 @@ .decl call_callee_name(c0:symbol,c1:symbol,c2:symbol) .decl call_caller_unknown(c0:symbol,c1:symbol) .decl call_chain_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol) -.decl call_class_kind(c0:symbol) .decl call_chain_summary(c0:symbol,c1:number) .decl call_class(c0:symbol,c1:symbol,c2:symbol) +.decl call_class_kind(c0:symbol) .decl call_context(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl call_ctor_implicit(c0:symbol,c1:symbol,c2:symbol) .decl call_ctor_target(c0:symbol,c1:symbol,c2:symbol) @@ -663,6 +663,7 @@ .decl module_lang(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl module_namespace_own(c0:symbol,c1:symbol,c2:symbol) .decl module_namespace_style(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl module_ns_chain(c0:symbol,c1:symbol,c2:symbol) .decl module_parse_errors(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl module_regime(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl module_target(c0:symbol,c1:symbol,c2:symbol,c3:symbol) @@ -948,6 +949,7 @@ .decl type_parent(c0:symbol,c1:symbol,c2:symbol) .decl type_placement(c0:symbol,c1:symbol,c2:symbol) .decl type_primary_ctor(c0:symbol,c1:symbol,c2:symbol) +.decl type_qn_tail(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_ref(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol) .decl type_ref_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_ref_argc(c0:symbol,c1:symbol,c2:symbol) diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/case.json b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/case.json new file mode 100644 index 000000000..d5cdce43f --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/case.json @@ -0,0 +1,20 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a qualified name written inside a namespace is read relative to it: Reflection.Info in namespace Shop is Shop.Reflection.Info", + "run": ["impact", "Info.Create"], + "want": ["[resolved] Machine.Guard"]}, + {"why": "the enclosing namespace of a nested one supplies the qualifier too (Shop.Orders sees Shop.Reflection)", + "run": ["impact", "Info.Create"], + "want": ["[resolved] Desk.Check"]}, + {"why": "control: from namespace Billing, Reflection.Info is no Shop type", + "run": ["impact", "Info.Create"], + "avoid": ["[resolved] Ledger.Post"]}, + {"why": "a using written inside a namespace names its namespace relative to it: `using Results;` in Shop.Audit imports Shop.Results", + "run": ["impact", "Failure.Describe"], + "want": ["[resolved] Auditor.Describes"]}, + {"why": "the constructor is reached through the same using", + "run": ["impact", "Failure."], + "want": ["Auditor.Describes"]}, + {"why": "control: `using Results;` in namespace Billing imports nothing of Shop", + "run": ["impact", "Failure.Describe"], + "avoid": ["[resolved] Ledger.Post"]}]} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Audit.cs b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Audit.cs new file mode 100644 index 000000000..bc827cc6f --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Audit.cs @@ -0,0 +1,13 @@ +namespace Shop.Audit; + +using Results; + +public class Auditor +{ + // `using Results;` written inside namespace Shop.Audit imports Shop.Results + public void Describes() + { + var failure = new Failure("x"); + failure.Describe(); + } +} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Elsewhere.cs b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Elsewhere.cs new file mode 100644 index 000000000..a19216293 --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Elsewhere.cs @@ -0,0 +1,15 @@ +// control: from a namespace with no Results or Reflection under it, the same names reach nothing in Shop +namespace Billing +{ + using Results; + + public class Ledger + { + public void Post() + { + var failure = new Failure("y"); + failure.Describe(); + var info = Reflection.Info.Create("ledger"); + } + } +} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Machine.cs b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Machine.cs new file mode 100644 index 000000000..d6694f891 --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Machine.cs @@ -0,0 +1,17 @@ +namespace Shop +{ + public class Machine + { + // `Reflection` is Shop.Reflection: a qualified name is read from the namespace it is written in + public void Guard() { var info = Reflection.Info.Create("guard"); } + } +} + +namespace Shop.Orders +{ + public class Desk + { + // from a namespace nested deeper, the enclosing Shop still supplies `Reflection` + public void Check() { var info = Reflection.Info.Create("desk"); } + } +} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Reflection.cs b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Reflection.cs new file mode 100644 index 000000000..667bd0a56 --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Reflection.cs @@ -0,0 +1,7 @@ +namespace Shop.Reflection +{ + public class Info + { + public static Info Create(string name) { return new Info(); } + } +} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Results.cs b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Results.cs new file mode 100644 index 000000000..c6a5aed15 --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Results.cs @@ -0,0 +1,8 @@ +namespace Shop.Results; + +public class Failure +{ + public Failure(string message) { Message = message; } + public string Message { get; } + public string Describe() { return Message; } +} diff --git a/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Shop.csproj b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Shop.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-namespace-name-is-read-from-its-namespace/src/Shop.csproj @@ -0,0 +1 @@ +net8.0 From d867f4fe3d846f7b7e6fa07ba640cd881963ebc3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:03 -0700 Subject: [PATCH 18/40] path: a C# source file is an endpoint `axiomcode path Tests/OrderTests.cs Order.Place` was refused with "nothing named 'Tests/OrderTests.cs' is declared" on every C# graph, although the file is indexed: the file-endpoint branch matched .ts/.tsx/.js/.mjs/.cjs/.py/.java only. Every extension a front end reads (ax_langs.BY_EXT) is now a file endpoint. An extension outside the old list falls through to the name lookup when no indexed file has that name, so `Owner.cs` still resolves a method `cs` of Owner (the case's control). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-path | 13 +++++++++---- .../a-source-file-is-a-path-endpoint/case.json | 14 ++++++++++++++ .../a-source-file-is-a-path-endpoint/src/Calc.cs | 8 ++++++++ .../src/Calc.csproj | 1 + .../a-source-file-is-a-path-endpoint/src/Checks.cs | 12 ++++++++++++ .../a-source-file-is-a-path-endpoint/src/Owner.cs | 8 ++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/cases/csharp/a-source-file-is-a-path-endpoint/case.json create mode 100644 tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.cs create mode 100644 tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.csproj create mode 100644 tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Checks.cs create mode 100644 tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Owner.cs diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 51c36cbd1..bb67ac133 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -42,7 +42,7 @@ look at, never as a path. import contextlib, io, json, os, re, sys, sqlite3, subprocess, tempfile, shutil, collections, glob, time HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract, ax_text +sys.path.insert(0, HERE); import dl_program, graph_sql, ax_edges, ax_contract, ax_text, ax_langs # The certainty table used to live here with SEVEN entries and a default of 5 for everything else. The # engine emits ELEVEN tiers, and the five it did not list all fell BELOW `defines` — which is not a call # at all — so on a JavaScript graph, where 8,916 of 25,048 traversable edges are `callback_registered`, @@ -557,13 +557,18 @@ class G: if not near: die(f"no method carries {s}, and this graph records NO decoration at all — not that the code has none." " A front end that does not project decorations cannot answer a decoration endpoint; ask by name instead.") die(f"no method carries {s}. Decorations in this graph: " + ', '.join(f"@{x['name']} ({x['n']})" for x in near)) - if re.search(r'\.(ts|tsx|js|mjs|cjs|py|java)$', s): # a file → every method in it + # a file → every method in it, for every source extension a front end reads (ax_langs.BY_EXT). An extension + # outside the first list below falls through to the name lookup when no file matches, so `Owner.cs` can still + # name a method `cs` of Owner. + legacy = re.search(r'\.(ts|tsx|js|mjs|cjs|py|java)$', s) + if legacy or os.path.splitext(s)[1] in ax_langs.BY_EXT: r = self.q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND method_id IS NOT NULL AND kind <> 'module'", s.lstrip('/'), f"%/{s.lstrip('/')}") if r: self.why_note('file name', [x['id'] for x in r], f"every method declared in a file named {s}") return f"{s} ({len(r)} methods)", [x['id'] for x in r] - self.why_note('file name', won=f"no indexed file named {s} declares a method") - die(f"no methods in a file named {s}") + if legacy: + self.why_note('file name', won=f"no indexed file named {s} declares a method") + die(f"no methods in a file named {s}") # the name as the agent writes it, normalised: Outer$Inner.m, Outer.Inner#m, m(int,String), pkg.Outer.Inner.m are all # Outer.Inner.m. Java's parser names a nested type without its outer (#667) and the index recovers most of them, so the # same name is tried with leading segments dropped — Outer.Inner.m, then Inner.m — against display AND qualified name, diff --git a/tests/cases/csharp/a-source-file-is-a-path-endpoint/case.json b/tests/cases/csharp/a-source-file-is-a-path-endpoint/case.json new file mode 100644 index 000000000..aeb092201 --- /dev/null +++ b/tests/cases/csharp/a-source-file-is-a-path-endpoint/case.json @@ -0,0 +1,14 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a C# source file is an endpoint: every method in it, as for a .java, .py or .ts file", + "run": ["path", "src/Checks.cs", "Adder.Twice"], + "want": ["Checker.Run", "Adder.Add", "Adder.Twice"], + "avoid": ["nothing named"]}, + {"why": "the file may be named by its last segments", + "run": ["path", "Checks.cs", "Adder.Add"], + "want": ["Checker.Run"], + "avoid": ["nothing named"]}, + {"why": "control: a method named like an extension (Owner.cs) is still that method when no file has the name", + "run": ["path", "Owner.cs", "Adder.Twice"], + "want": ["Owner.cs", "Adder.Twice"], + "avoid": ["no methods in a file named"]}]} diff --git a/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.cs b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.cs new file mode 100644 index 000000000..dcc37da03 --- /dev/null +++ b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.cs @@ -0,0 +1,8 @@ +namespace Calc +{ + public class Adder + { + public int Add(int a, int b) { return Twice(a) + b; } + int Twice(int a) { return a * 2; } + } +} diff --git a/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.csproj b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Calc.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Checks.cs b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Checks.cs new file mode 100644 index 000000000..63c0481c2 --- /dev/null +++ b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Checks.cs @@ -0,0 +1,12 @@ +namespace Calc +{ + public class Checker + { + public void Run() { new Adder().Add(1, 2); } + } + + public class Lone + { + public void Idle() { } + } +} diff --git a/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Owner.cs b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Owner.cs new file mode 100644 index 000000000..3c5d7f262 --- /dev/null +++ b/tests/cases/csharp/a-source-file-is-a-path-endpoint/src/Owner.cs @@ -0,0 +1,8 @@ +namespace Calc +{ + public class Owner + { + // a method whose name reads like a file extension: `Owner.cs` names it, not a file + public void cs() { new Adder().Add(3, 4); } + } +} From ea42c61224de8d2604583dfeb779aea9ff5ce7f8 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:03 -0700 Subject: [PATCH 19/40] csharp: a using resource is typed by its initializer `using (var w = new Writer(..)) { w.Write(..); }` and `using var w = new Writer();` left the receiver untyped: var_value_initializer read LOCAL and CONST declarations only, and the IR gives these the declarationKind USING. The resource is the disposable itself, so its initializer is a value of its type exactly as a local's is. Case a-using-resource-is-typed-by-its-initializer: both shapes fail on the base; the control (a foreach variable is typed by its element, never by the collection) passes on both. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/csharp/engine/projections/variables.dl | 4 ++ .../case.json | 11 +++++ .../src/Files.cs | 49 +++++++++++++++++++ .../src/Files.csproj | 1 + 4 files changed, 65 insertions(+) create mode 100644 tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/case.json create mode 100644 tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.cs create mode 100644 tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.csproj diff --git a/graph/csharp/engine/projections/variables.dl b/graph/csharp/engine/projections/variables.dl index 27be6bf5f..841c1f86c 100644 --- a/graph/csharp/engine/projections/variables.dl +++ b/graph/csharp/engine/projections/variables.dl @@ -61,6 +61,10 @@ var_initializer("lib", v, e) :- // The initializer IS a value of the variable's type. var_value_initializer(prov, v, e) :- var_initializer(prov, v, e), var_decl(prov, _, "LOCAL", _, v). var_value_initializer(prov, v, e) :- var_initializer(prov, v, e), var_decl(prov, _, "CONST", _, v). +// A `using` resource -- `using (var w = new Writer())` and `using var w = ...;` -- is +// the disposable itself, so its initializer is a value of its type exactly as a +// local's is. Without it every call on such a resource has an untyped receiver. +var_value_initializer(prov, v, e) :- var_initializer(prov, v, e), var_decl(prov, _, "USING", _, v). // The initializer is a COLLECTION and the variable takes its element type. var_iterated_initializer(prov, v, e) :- var_initializer(prov, v, e), var_decl(prov, _, "FOREACH", _, v). diff --git a/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/case.json b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/case.json new file mode 100644 index 000000000..759af3182 --- /dev/null +++ b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/case.json @@ -0,0 +1,11 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a using statement's resource is typed by its initializer: `using (var w = new Writer())` then w.Write", + "run": ["impact", "Writer.Write"], + "want": ["[resolved] Jobs.Statement"]}, + {"why": "a using declaration likewise: `using var w = new Writer();`", + "run": ["impact", "Writer.Write"], + "want": ["[resolved] Jobs.Declaration"]}, + {"why": "control: a foreach variable is not typed as the collection it iterates", + "run": ["impact", "Shelf.Read"], + "avoid": ["[resolved] Jobs.Loop"]}]} diff --git a/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.cs b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.cs new file mode 100644 index 000000000..3f0e91e36 --- /dev/null +++ b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Files +{ + public abstract class Sink { } + + public class Writer : Sink, IDisposable + { + public void Write(string s) { } + public void Dispose() { } + } + + public class Book + { + public void Read() { } + } + + public class Shelf : IEnumerable + { + public void Read() { } + public IEnumerator GetEnumerator() { yield break; } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class Jobs + { + public void Statement() + { + using (var w = new Writer()) + { + w.Write("a"); + } + } + + public void Declaration() + { + using var w = new Writer(); + w.Write("b"); + } + + public void Loop(Shelf shelf) + { + // control: a foreach variable takes the ELEMENT type, never the collection's + foreach (var b in shelf) b.Read(); + } + } +} diff --git a/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.csproj b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-using-resource-is-typed-by-its-initializer/src/Files.csproj @@ -0,0 +1 @@ +net8.0 From 59a1ce93cdc7ce99a0018d58265693dce0efa65c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:04 -0700 Subject: [PATCH 20/40] csharp: a constructor runs its base class's parameterless constructor A constructor with no `: base(..)` / `: this(..)` runs `base()` before its body, and a class that declares no constructor gets an implicit one that does the same. Neither is written, so the base constructor had no caller: an abstract base whose constructor every derived class runs read as uncalled. New synthesised call_edges kind implicit_base_ctor (known_edge): - from a declared constructor without an initializer (and not a primary constructor whose heritage passes arguments), FromExpr = the base's heritage type reference, as primary_ctor_base does; - from a `new T()` site where T declares no constructor, beside its known_implicit_ctor row. The target is the base's constructor that takes no argument, walking up through bases that declare none. Registered in the bundle schema and ax_edges' kind vocabulary (`ctor`). Case a-constructor-runs-its-base-constructor: 4 positives fail on the base; the controls (`: base(name)` and `: this(..)` constructors gain no implicit edge) pass on both. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 1 + graph/bundle/schema.ts | 1 + .../engine/call-edge-generation/call_chain.dl | 46 +++++++++++++++++++ graph/csharp/souffle/decls_all.dl | 3 ++ .../skills/axiomcode/scripts/ax_edges.py | 2 + .../case.json | 21 +++++++++ .../src/Maps.cs | 41 +++++++++++++++++ .../src/Maps.csproj | 1 + .../src/Use.cs | 9 ++++ 9 files changed, 125 insertions(+) create mode 100644 tests/cases/csharp/a-constructor-runs-its-base-constructor/case.json create mode 100644 tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.cs create mode 100644 tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.csproj create mode 100644 tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Use.cs diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 2c17b3916..75b60d9b6 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -467,6 +467,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `new` | csharp | An object creation. The target is the constructed type's constructor, and it is never dispatched. | | `ctor_delegate` | csharp | `: this(...)` or `: base(...)`. No name is written, so the target is structural. | | `primary_ctor_base` | csharp | SYNTHESISED. A primary constructor's base invocation, written in the heritage clause: `class D(int a) : B(a)`. There is no call syntax anywhere in the body. FromExpr is the heritage type reference. | +| `implicit_base_ctor` | csharp | SYNTHESISED. The base class's parameterless constructor that a constructor with no `: base(...)` / `: this(...)` runs before its body, or that the implicit constructor of a class declaring none runs at its `new`. FromExpr is the heritage type reference, or the `new` site. | | `delegate` | csharp | A call through a delegate value: `handler(x)` or `handler.Invoke(x)`. | | `operator` | csharp | A user-defined operator invoked by operator syntax. | | `conversion` | csharp | A user-defined conversion. An implicit one has no syntax at the call site. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 2e9af3227..b687c2744 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -358,6 +358,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'new', languages: C, meaning: 'An object creation. The target is the constructed type\'s constructor, and it is never dispatched.' }, { table: 'call_edges', column: 'kind', value: 'ctor_delegate', languages: C, meaning: '`: this(...)` or `: base(...)`. No name is written, so the target is structural.' }, { table: 'call_edges', column: 'kind', value: 'primary_ctor_base', languages: C, meaning: 'SYNTHESISED. A primary constructor\'s base invocation, written in the heritage clause: `class D(int a) : B(a)`. There is no call syntax anywhere in the body. FromExpr is the heritage type reference.' }, + { table: 'call_edges', column: 'kind', value: 'implicit_base_ctor', languages: C, meaning: 'SYNTHESISED. The base class\'s parameterless constructor that a constructor with no `: base(...)` / `: this(...)` runs before its body, or that the implicit constructor of a class declaring none runs at its `new`. FromExpr is the heritage type reference, or the `new` site.' }, { table: 'call_edges', column: 'kind', value: 'delegate', languages: C, meaning: 'A call through a delegate value: `handler(x)` or `handler.Invoke(x)`.' }, { table: 'call_edges', column: 'kind', value: 'operator', languages: C, meaning: 'A user-defined operator invoked by operator syntax.' }, { table: 'call_edges', column: 'kind', value: 'conversion', languages: C, meaning: 'A user-defined conversion. An implicit one has no syntax at the call site.' }, diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index 69647979e..c0e3de06e 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -286,6 +286,52 @@ call_chain_edge(tr, caller, "-", ctor, "lib", "boundary_lib", "primary_ctor_base method_decl("lib", _, _, _, ctor), type_ctor("client", gk, caller). +// ── SYNTHESISED EDGES: THE IMPLICIT `base()` ──────────────────────────────── +// A constructor that writes neither `: base(..)` nor `: this(..)` runs its base +// class's parameterless constructor before its own body, and a class that declares +// no constructor at all gets one that does the same. Neither is written anywhere, so +// without these edges a base constructor that every derived class runs has no caller. +// +// implicit_base_ctor(TypeGroup, Ctor): the constructor `base()` runs for a type -- +// its base class's constructor that takes no argument, or, where the base declares +// no constructor either, the one the base's own implicit constructor runs. +implicit_base_ctor(gk, ctor) :- + type_extends("client", gk, bgk), + type_ctor("client", bgk, ctor), ctor_accepts_argc("client", ctor, "0"). +implicit_base_ctor(gk, ctor) :- + type_extends("client", gk, bgk), + !type_ctor("client", bgk, _), type_group("client", _, bgk), + implicit_base_ctor(bgk, ctor). + +// A constructor that hands off with `: this(..)` or `: base(..)`, or a primary +// constructor whose heritage passes arguments: the base constructor it runs is the +// one written there, already an edge. +ctor_has_initializer(m) :- call_is_base_ctor("client", e), expr_ultimate_method("client", e, m). +ctor_has_initializer(m) :- call_is_this_ctor("client", e), expr_ultimate_method("client", e, m). +ctor_has_initializer(m) :- + heritage_has_ctor_args("client", t, _), type_group("client", t, gk), type_ctor("client", gk, m). + +// The FromExpr is the base class's entry in the heritage clause, as for a primary +// constructor's base invocation: the closest thing to where the call is written. +implicit_base_site(gk, tr) :- + type_extends("client", gk, bgk), + heritage_resolves("client", gk, pos, bgk), + heritage_of_entity("client", gk, pos, _, _, tr), tr != "". + +// (1) A declared constructor with no initializer. +call_chain_edge(tr, m, "-", ctor, "client", "known_edge", "implicit_base_ctor") :- + type_ctor("client", gk, m), method_decl("client", _, _, _, m), + !ctor_has_initializer(m), + implicit_base_ctor(gk, ctor), + implicit_base_site(gk, tr). + +// (2) `new T()` where T declares no constructor: the implicit one runs T's base's. +// The edge sits on the `new` site itself, beside its known_implicit_ctor row. +call_chain_edge(e, caller, "-", ctor, "client", "known_edge", "implicit_base_ctor") :- + call_ctor_implicit("client", e, gk), + implicit_base_ctor(gk, ctor), + call_from_expr("client", e, caller). + // ── ACCESSOR EDGES: A PROPERTY READ IS A CALL ─────────────────────────────── // `x.Name` invokes get_Name, `a[i]` an indexer accessor, `e += h` an add accessor. // These have no cs_call_site row -- the parser cannot know whether `x.Name` is a diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 2e4548373..778520efb 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -265,6 +265,7 @@ .decl ctl_route_c(c0:symbol,c1:symbol,c2:symbol) .decl ctl_route_tok(c0:symbol,c1:symbol,c2:symbol) .decl ctor_accepts_argc(c0:symbol,c1:symbol,c2:symbol) +.decl ctor_has_initializer(c0:symbol) .decl ctor_implicit_type(c0:symbol,c1:symbol) .decl decl_return_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl delegate_arg_expr(c0:symbol,c1:symbol) @@ -487,8 +488,10 @@ .decl implements_member(c0:symbol,c1:symbol,c2:symbol) .decl implements_params_conflict(c0:symbol,c1:symbol) .decl implements_shape(c0:symbol,c1:symbol,c2:symbol) +.decl implicit_base_ctor(c0:symbol,c1:symbol) .decl implicit_base_group(c0:symbol,c1:symbol) .decl implicit_base_name(c0:symbol) +.decl implicit_base_site(c0:symbol,c1:symbol) .decl implicit_new_untargeted(c0:symbol,c1:symbol,c2:symbol) .decl indexer_access(c0:symbol,c1:symbol,c2:symbol) .decl indexer_getter(c0:symbol,c1:symbol,c2:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index f5283b76a..4e1a9965d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -103,6 +103,8 @@ 'new': 'new', 'CONSTRUCTOR_CALL': 'new', 'anon_new': 'new', 'METACLASS_CREATION': 'new', # one constructor to another 'ctor_delegate': 'ctor', 'SUPER_CALL': 'super', + # C#: a base constructor no syntax names: the implicit `base()` a constructor without an initializer runs + 'implicit_base_ctor': 'ctor', # the callable is named, not called at that line — it runs when whoever took it runs it 'ref': 'method-ref', # a declaration handed to a decorator, which is what wires most framework handlers up diff --git a/tests/cases/csharp/a-constructor-runs-its-base-constructor/case.json b/tests/cases/csharp/a-constructor-runs-its-base-constructor/case.json new file mode 100644 index 000000000..0b0cde40c --- /dev/null +++ b/tests/cases/csharp/a-constructor-runs-its-base-constructor/case.json @@ -0,0 +1,21 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a constructor with no initializer runs its base class's parameterless constructor", + "run": ["impact", "src/Maps.cs:7", "--depth", "1"], + "want": ["[resolved] Map. src/Maps.cs:15"]}, + {"why": "through a generic base: PersonMap() runs Map()", + "run": ["impact", "src/Maps.cs:15", "--depth", "1"], + "want": ["[resolved] PersonMap. src/Maps.cs:21"]}, + {"why": "a class that declares no constructor runs its base's at every `new`", + "run": ["impact", "src/Maps.cs:15", "--depth", "1"], + "want": ["[resolved] Use.Plain"]}, + {"why": "control: `: base(name)` runs the named constructor, so the parameterless one gains no resolved caller from it", + "run": ["impact", "src/Maps.cs:7", "--depth", "1"], + "avoid": ["[resolved] NamedMap."]}, + {"why": "control: a constructor that delegates with `: this(..)` runs no base constructor itself (the one it delegates to does)", + "run": ["impact", "src/Maps.cs:7", "--depth", "1"], + "want": ["[resolved] SelfMap. src/Maps.cs:39"], + "avoid": ["[resolved] SelfMap. src/Maps.cs:38"]}, + {"why": "the explicit base call keeps its own edge", + "run": ["impact", "src/Maps.cs:8"], + "want": ["[resolved] NamedMap."]}]} diff --git a/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.cs b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.cs new file mode 100644 index 000000000..18c78d863 --- /dev/null +++ b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.cs @@ -0,0 +1,41 @@ +using System; + +namespace Maps +{ + public abstract class Map + { + protected Map() { Register(); } + protected Map(string name) { Register(); } + void Register() { } + } + + public abstract class Map : Map + { + // no initializer: runs Map() before its body + protected Map() { } + } + + public class PersonMap : Map + { + // no initializer, generic base: runs Map() and through it Map() + public PersonMap() { Console.WriteLine("person"); } + } + + public class PlainMap : Map + { + // no constructor at all: its implicit one runs Map() + } + + public class NamedMap : Map + { + // control: an explicit `: base(name)` runs that constructor, not the parameterless one + public NamedMap(string name) : base(name) { } + } + + public class SelfMap : Map + { + // control: `: this(..)` delegates; this constructor itself runs no base constructor + public SelfMap() : this(1) { } + public SelfMap(int x) { } + } +} diff --git a/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.csproj b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Maps.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Use.cs b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Use.cs new file mode 100644 index 000000000..fc56a8796 --- /dev/null +++ b/tests/cases/csharp/a-constructor-runs-its-base-constructor/src/Use.cs @@ -0,0 +1,9 @@ +namespace Maps +{ + public static class Use + { + public static void Person() { var m = new PersonMap(); } + public static void Plain() { var m = new PlainMap(); } + public static void Named() { var m = new NamedMap("n"); } + } +} From 0966ce7b07338e6599850d2f6cc3b2a3edbdc7d7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:04 -0700 Subject: [PATCH 21/40] csharp: an unstaged receiver keeps its type name for extension matching `var services = new ServiceCollection(); services.AddWidgets();` and `Configure(s => s.AddWidgets())` with `Configure(Action c)` never reached the client's `AddWidgets(this IServiceCollection s)`: both receivers' types are unstaged, so they have no group, and call_recv_type_name -- the NAME the by-name rung (3) and the loose DI rung (7, #1472) compare -- had no clause for either. call_recv_type_name now also gives - an implicitly typed local initialised with `new T(..)`: the T written there; - an implicitly typed lambda parameter: the name of the delegate argument that types it (lambda_param_arg_ref, the same reference param_type reads). So the lambda's call matches by name (resolved) and the local's is a rung-7 candidate (one of a set, keeping its external label). Case an-unstaged-receiver-keeps-its-type-name: both positives fail on the base; controls (an extension on an in-source `this` type, a lambda typed as another unstaged type) pass on both; extension-on-unstaged-receiver unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/csharp/engine/resolution/extensions.dl | 19 +++++++++++++ .../case.json | 14 ++++++++++ .../src/App.csproj | 10 +++++++ .../src/Program.cs | 27 +++++++++++++++++++ .../src/Widgets.cs | 16 +++++++++++ 5 files changed, 86 insertions(+) create mode 100644 tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/case.json create mode 100644 tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/App.csproj create mode 100644 tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Program.cs create mode 100644 tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Widgets.cs diff --git a/graph/csharp/engine/resolution/extensions.dl b/graph/csharp/engine/resolution/extensions.dl index 235164142..5a09820c7 100644 --- a/graph/csharp/engine/resolution/extensions.dl +++ b/graph/csharp/engine/resolution/extensions.dl @@ -118,6 +118,25 @@ call_recv_type_name(prov, e, tn) :- call_recv_type_name(prov, e, tn) :- call_receiver_expr(prov, e, r), ref_denotes(prov, r, "LAMBDA_PARAMETER", p), param_type_name(prov, p, tn, _). +// AN IMPLICITLY TYPED LAMBDA PARAMETER takes its type from the delegate it is +// converted to (lambda-parameters.dl): `Configure(s => s.AddRules())` with +// `Configure(Action c)` makes `s` an IServiceCollection. Where +// that type is in source, param_type already says so; where it is unstaged, the +// NAME is all there is, and without it an extension written for +// `this IServiceCollection` cannot match the lambda that calls it. +call_recv_type_name(prov, e, tn) :- + call_receiver_expr(prov, e, r), + ref_denotes(prov, r, "LAMBDA_PARAMETER", p), + lambda_param_arg_ref(prov, p, ar), type_ref_name(prov, ar, tn), tn != "". +// AN IMPLICITLY TYPED LOCAL made by `new T(..)` holds a T, resolved or not: +// `var services = new ServiceCollection(); services.AddRules();`. The name is the +// one written at the creation (external-types.dl labels the boundary from the same +// fact); without it the extension rungs have nothing to compare. +call_recv_type_name(prov, e, tn) :- + call_receiver_expr(prov, e, r), + ref_denotes(prov, r, "LOCAL_VARIABLE", v), var_is_implicit(prov, v), + var_value_initializer(prov, v, init), expr_kind(prov, init, "OBJECT_CREATION"), + call_callee_name(prov, init, tn), tn != "". // A receiver that is itself a call: the callee's declared return type name. The ROOT // reference's name where there is one (`List`, not `List`), which is how every // other receiver is named; the written text only where no reference was recorded. diff --git a/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/case.json b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/case.json new file mode 100644 index 000000000..e9a105eed --- /dev/null +++ b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/case.json @@ -0,0 +1,14 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a local initialised with `new ServiceCollection()` keeps that name, so a this-IServiceCollection extension is a candidate on it, one of a set (#1472's rung)", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "want": ["[one of a set] Program.FromNew"]}, + {"why": "a lambda parameter typed by Action is an IServiceCollection: the extension written for it matches by name", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "want": ["[resolved] Program. src/Program.cs:16"]}, + {"why": "control: an unstaged receiver never matches an extension on an in-source type", + "run": ["impact", "WidgetExtensions.AddGadgets"], + "avoid": ["Program.Control"]}, + {"why": "control: a lambda typed Action is not resolved to the IServiceCollection extension", + "run": ["impact", "WidgetExtensions.AddWidgetRules"], + "avoid": ["[resolved] Program. src/Program.cs:26"]}]} diff --git a/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/App.csproj b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/App.csproj new file mode 100644 index 000000000..6240b3413 --- /dev/null +++ b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/App.csproj @@ -0,0 +1,10 @@ + + + net8.0 + Exe + enable + + + + + diff --git a/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Program.cs b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Program.cs new file mode 100644 index 000000000..581198a38 --- /dev/null +++ b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Program.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using App.Widgets; + +public static class Program +{ + public static void Configure(Action configure) { } + public static void Tune(Action tune) { } + + public static void FromNew() + { + var services = new ServiceCollection(); + services.AddWidgetRules(); + } + + public static void FromLambda() => Configure(s => s.AddWidgetRules()); + + // control: an unstaged receiver never matches an extension whose `this` type is in source + public static void Control() + { + var g = new Gizmo(); + g.AddGadgets(); + } + + // control: a lambda typed as another unstaged type is no by-name match for this IServiceCollection + public static void OtherLambda() => Tune(z => z.AddWidgetRules()); +} diff --git a/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Widgets.cs b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Widgets.cs new file mode 100644 index 000000000..83b04b438 --- /dev/null +++ b/tests/cases/csharp/an-unstaged-receiver-keeps-its-type-name/src/Widgets.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +namespace App.Widgets; + +public interface IRule { bool Check(); } +public class SizeRule : IRule { public bool Check() => true; } +public class Gadget { } + +public static class WidgetExtensions +{ + public static IServiceCollection AddWidgetRules(this IServiceCollection s) + { + s.AddSingleton(); + return s; + } + public static Gadget AddGadgets(this Gadget g) => g; +} From 21917afd7c6d602203beaf2ac0976b8c595116d5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:04 -0700 Subject: [PATCH 22/40] csharp: a static member read through its type name types the chain `var f = Context.Current.Factory; f.Create()` -- the getter of the static property `Current` was an edge, but its result had no type: expr_type for a MEMBER_ACCESS needed an expr_type on the qualifier, and a qualifier that names a TYPE has none. So `.Factory` and every call after it were unresolved. Two clauses mirror the instance ones with ref_names_type on the qualifier (property and field). ref_names_type already refuses a name that also binds a value, the language's member-over-type rule. Case a-static-member-read-types-the-chain: four shapes (local, one chain, a var of the static read, a static field) fail on the base; the "Color Color" control -- a property named like the type is the value read -- passes on both. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 19 ++++++ .../case.json | 66 +++++++++++++++++++ .../src/Ctx.cs | 17 +++++ .../src/Ctx.csproj | 1 + .../src/Use.cs | 17 +++++ 5 files changed, 120 insertions(+) create mode 100644 tests/cases/csharp/a-static-member-read-types-the-chain/case.json create mode 100644 tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.cs create mode 100644 tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.csproj create mode 100644 tests/cases/csharp/a-static-member-read-types-the-chain/src/Use.cs diff --git a/graph/csharp/engine/expression-resolution/expr-type.dl b/graph/csharp/engine/expression-resolution/expr-type.dl index 5c9623793..fe5e35e7e 100644 --- a/graph/csharp/engine/expression-resolution/expr-type.dl +++ b/graph/csharp/engine/expression-resolution/expr-type.dl @@ -459,6 +459,25 @@ expr_type(prov, e, gk) :- expr_type(prov, q, qgk), member_lookup(prov, qgk, n, "field", f), field_type(prov, f, gk). +// A STATIC member read through its TYPE NAME: `Context.Current.Factory.Create()`. +// The qualifier `Context` names a type, not a value, so it has no expr_type and the +// two clauses above cannot start from it: the getter of `Current` is an edge +// (properties.dl reads the type name), and these give its RESULT a type, so that +// `.Factory` and every call after it resolve. ref_names_type already refuses a name +// that also binds a value, which is the language's member-over-type rule. +expr_type(prov, e, gk) :- + expr_kind(prov, e, "MEMBER_ACCESS"), + expr_qualifier_child(prov, e, q), expr_member_name_child(prov, e, nameExpr), + expr_written_name(prov, nameExpr, n), + ref_names_type(prov, q, qgk), + member_lookup(prov, qgk, n, "property", p), property_type(prov, p, gk). +expr_type(prov, e, gk) :- + expr_kind(prov, e, "MEMBER_ACCESS"), + expr_qualifier_child(prov, e, q), expr_member_name_child(prov, e, nameExpr), + expr_written_name(prov, nameExpr, n), + ref_names_type(prov, q, qgk), + member_lookup(prov, qgk, n, "field", f), field_type(prov, f, gk). + // ── A GENERIC ARGUMENT, SUBSTITUTED FOR THE PARAMETER IT FILLS ────────────── // `IWrap w; w.Value.Flag` -- `Value` is declared `T`, and without binding // T to Settings the access resolves and the type of the RESULT is the type parameter, diff --git a/tests/cases/csharp/a-static-member-read-types-the-chain/case.json b/tests/cases/csharp/a-static-member-read-types-the-chain/case.json new file mode 100644 index 000000000..4170ca101 --- /dev/null +++ b/tests/cases/csharp/a-static-member-read-types-the-chain/case.json @@ -0,0 +1,66 @@ +{ + "lang": "csharp", + "src": "src", + "checks": [ + { + "why": "a static property read through its type name has the property's type: `var f = Ctx.Current.F; f.Create()`", + "run": [ + "impact", + "Factory.Create" + ], + "want": [ + "[resolved] Use.Local" + ] + }, + { + "why": "the same chain written in one expression", + "run": [ + "impact", + "Factory.Create" + ], + "want": [ + "[resolved] Use.Chain" + ] + }, + { + "why": "a local initialised from the static read takes its type", + "run": [ + "impact", + "Factory.Create" + ], + "want": [ + "[resolved] Use.ViaVar" + ] + }, + { + "why": "a static field read through its type name likewise", + "run": [ + "impact", + "Factory.Create" + ], + "want": [ + "[resolved] Use.Field" + ] + }, + { + "why": "control: a property named like the type is the value read, so its own type decides", + "run": [ + "impact", + "Factory.Create" + ], + "avoid": [ + "Holder.Other" + ] + }, + { + "why": "and that property's type does reach its own factory", + "run": [ + "impact", + "OtherFactory.Create" + ], + "want": [ + "[resolved] Holder.Other" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.cs b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.cs new file mode 100644 index 000000000..b1c00ba06 --- /dev/null +++ b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.cs @@ -0,0 +1,17 @@ +namespace N +{ + public interface IFactory { object Create(); } + public class Factory : IFactory { public object Create() { return null; } } + public interface IOtherFactory { object Create(); } + public class OtherFactory : IOtherFactory { public object Create() { return null; } } + + public interface ICtx { IFactory F { get; } } + public interface IOther { IOtherFactory F { get; } } + + public class Ctx : ICtx + { + public static ICtx Current { get; set; } + public static ICtx Shared; + public IFactory F { get; } + } +} diff --git a/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.csproj b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Ctx.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/a-static-member-read-types-the-chain/src/Use.cs b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Use.cs new file mode 100644 index 000000000..570d5ae96 --- /dev/null +++ b/tests/cases/csharp/a-static-member-read-types-the-chain/src/Use.cs @@ -0,0 +1,17 @@ +namespace N +{ + public static class Use + { + public static object Local() { var f = Ctx.Current.F; return f.Create(); } + public static object Chain() { return Ctx.Current.F.Create(); } + public static object ViaVar() { var c = Ctx.Current; return c.F.Create(); } + public static object Field() { return Ctx.Shared.F.Create(); } + } + + public static class Holder + { + // control: inside Holder, `Ctx` is this property (the member wins over the type of the same name) + public static IOther Ctx { get; } + public static object Other() { return Ctx.F.Create(); } + } +} From c0dab0b4471ea4a98764347af15be6b2093fe7fa Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:24:05 -0700 Subject: [PATCH 23/40] csharp: a constructed generic type is a static receiver `Cache.Get("k")`, `Registry.Create()` and `Pair.Make().Use()` were unresolved: the parser emitted a generic_name in expression position with typeArgumentCount set and NO potentialQualifiedName, so the receiver had no name to resolve. - parser: a generic_name row carries its identifier as the written name (its arity stays in typeArgumentCount). - engine: expr_type_argc projects the count; ref_names_type and the call receiver's type_ref_demand resolve the name at that arity, so `Cache` names Cache`1 and never a non-generic Cache. Every receiver written without type arguments has arity "0", which is what both clauses used before. Case a-generic-type-name-is-a-static-receiver: three positives fail on the base; the non-generic `Cache.Get` control resolves to its own type on both. Parser C# suite 62/62. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../csharp/engine/projections/expressions.dl | 9 ++++++++ .../csharp/engine/resolution/local-binding.dl | 7 +++--- .../engine/resolution/type-resolution.dl | 4 ++-- graph/csharp/souffle/decls_all.dl | 1 + .../extractors/cs-expression-extractor.ts | 7 ++++++ .../case.json | 18 +++++++++++++++ .../src/Cache.cs | 22 +++++++++++++++++++ .../src/Cache.csproj | 1 + .../src/Use.cs | 10 +++++++++ 9 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 tests/cases/csharp/a-generic-type-name-is-a-static-receiver/case.json create mode 100644 tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.cs create mode 100644 tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.csproj create mode 100644 tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Use.cs diff --git a/graph/csharp/engine/projections/expressions.dl b/graph/csharp/engine/projections/expressions.dl index 46e28c769..8a82e1bac 100644 --- a/graph/csharp/engine/projections/expressions.dl +++ b/graph/csharp/engine/projections/expressions.dl @@ -117,6 +117,15 @@ expr_written_name("lib", e, n) :- lib_cs_expression(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, n, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e), n != "". +// ── expr_type_argc(Prov, ExprHash, TypeArgumentCount) ────────────────────── +// The type arguments written on a name: `Cache` is the name `Cache` at arity 1. +// A generic name used as a receiver or qualifier is resolved at this arity, since +// `Cache` and `Cache` are two types. +expr_type_argc("client", e, tac) :- + cs_expression(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, tac, _, _, _, _, _, _, _, _, _, _, _, _, e). +expr_type_argc("lib", e, tac) :- + lib_cs_expression(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, tac, _, _, _, _, _, _, _, _, _, _, _, _, e). + // ── expr_literal(Prov, ExprHash, LiteralKind, LiteralValue) ───────────────── expr_literal("client", e, lk, lv) :- cs_expression(_, _, _, _, _, _, _, _, _, _, lk, lv, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e), diff --git a/graph/csharp/engine/resolution/local-binding.dl b/graph/csharp/engine/resolution/local-binding.dl index f2ec9440b..e7bc391fa 100644 --- a/graph/csharp/engine/resolution/local-binding.dl +++ b/graph/csharp/engine/resolution/local-binding.dl @@ -220,7 +220,8 @@ ref_unbound(prov, e, n) :- !ref_names_type(prov, e, _). // A reference that names a TYPE rather than a value: the receiver of a static call. -// Resolved through type_name_resolves in the file's scope. +// Resolved through type_name_resolves in the file's scope, at the arity written on +// it: `Cache.Get()` names Cache`1, not a non-generic Cache. // // NOT WHERE THE NAME ALSO BINDS TO A VALUE. C# allows a member and a type to share a // name (the language's own "Color Color" rule) and prefers the MEMBER in an @@ -235,10 +236,10 @@ ref_binds_value(prov, e) :- ref_denotes(prov, e, _, _). ref_binds_value(prov, e) :- local_binds(prov, e, _). ref_names_type(prov, e, gk) :- - expr_written_name(prov, e, n), + expr_written_name(prov, e, n), expr_type_argc(prov, e, ar), expr_ultimate_module(prov, e, mod), !ref_binds_value(prov, e), - type_name_resolves(prov, mod, n, "0", gk). + type_name_resolves(prov, mod, n, ar, gk). // ── IMPLICIT `this` ───────────────────────────────────────────────────────── // An unqualified instance member reference has an implicit `this` receiver whose diff --git a/graph/csharp/engine/resolution/type-resolution.dl b/graph/csharp/engine/resolution/type-resolution.dl index 1c76e1cba..f4d1b8031 100644 --- a/graph/csharp/engine/resolution/type-resolution.dl +++ b/graph/csharp/engine/resolution/type-resolution.dl @@ -231,10 +231,10 @@ type_ref_demand(prov, mod, n, ar) :- // a type name and produced `external:s.Describe` -- an external target named after a // local variable, which is a wrong answer rather than a missing one. The !ref_denotes // term is what makes the guard "unbound by anyone". -type_ref_demand(prov, mod, n, "0") :- +type_ref_demand(prov, mod, n, ar) :- call_receiver_expr(prov, e, r), expr_ref_unknown(prov, r), - expr_written_name(prov, r, n), + expr_written_name(prov, r, n), expr_type_argc(prov, r, ar), !ref_denotes(prov, r, _, _), call_module(prov, e, mod). diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index 778520efb..89a6ff361 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -360,6 +360,7 @@ .decl expr_target_any(c0:symbol,c1:symbol,c2:symbol) .decl expr_target_type(c0:symbol,c1:symbol,c2:symbol) .decl expr_type(c0:symbol,c1:symbol,c2:symbol) +.decl expr_type_argc(c0:symbol,c1:symbol,c2:symbol) .decl expr_type_owner(c0:symbol,c1:symbol,c2:symbol) .decl expr_type_via_ref(c0:symbol,c1:symbol,c2:symbol) .decl expr_ultimate_method(c0:symbol,c1:symbol,c2:symbol) diff --git a/parser/src/parsers/csharp/extractors/cs-expression-extractor.ts b/parser/src/parsers/csharp/extractors/cs-expression-extractor.ts index d0fd6d4df..b7abc63c8 100644 --- a/parser/src/parsers/csharp/extractors/cs-expression-extractor.ts +++ b/parser/src/parsers/csharp/extractors/cs-expression-extractor.ts @@ -2142,6 +2142,13 @@ function describeExpression( const argumentList = childOfType(node, 'type_argument_list'); shape.typeArgumentCount = argumentList === undefined ? 0 : namedChildren(argumentList).length; + // The name without its arguments, as an `identifier` row carries it; the + // arity is typeArgumentCount. Without it the receiver of `Cache.Get()` + // has no name, and nothing can say which type it names. + const name = childOfType(node, 'identifier'); + if (name !== undefined) { + shape.potentialQualifiedName = normalizeCSharpIdentifier(name.text); + } break; } diff --git a/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/case.json b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/case.json new file mode 100644 index 000000000..acc2d4e08 --- /dev/null +++ b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a static call through a constructed generic type, `Cache.Get()`, reaches the generic type's member", + "run": ["impact", "src/Cache.cs:5"], + "want": ["[resolved] Use.Typed"]}, + {"why": "control: the non-generic type of the same name is not reached from the generic receiver", + "run": ["impact", "src/Cache.cs:12"], + "want": ["[resolved] Use.Plain"], + "avoid": ["[resolved] Use.Typed"]}, + {"why": "and the generic member is not reached from the non-generic receiver", + "run": ["impact", "src/Cache.cs:5"], + "avoid": ["[resolved] Use.Plain"]}, + {"why": "a call on the result of a static call through a generic type", + "run": ["impact", "Pair.Use"], + "want": ["[resolved] Use.Chained"]}, + {"why": "a static field read through a generic type name types the chain", + "run": ["impact", "Store.Touch"], + "want": ["[resolved] Use.Member"]}]} diff --git a/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.cs b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.cs new file mode 100644 index 000000000..5bf51e295 --- /dev/null +++ b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.cs @@ -0,0 +1,22 @@ +namespace N +{ + public static class Cache + { + public static T Get(string key) { return default(T); } + public static Store Shared = new Store(); + } + + public static class Cache + { + // a non-generic type of the same name: `Cache.Get` is this one + public static object Get(string key) { return null; } + } + + public class Store { public void Touch() { } } + + public class Pair + { + public static Pair Make() { return new Pair(); } + public void Use() { } + } +} diff --git a/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.csproj b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Cache.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Use.cs b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Use.cs new file mode 100644 index 000000000..a0229463c --- /dev/null +++ b/tests/cases/csharp/a-generic-type-name-is-a-static-receiver/src/Use.cs @@ -0,0 +1,10 @@ +namespace N +{ + public static class Use + { + public static int Typed() { return Cache.Get("x"); } + public static void Chained() { Pair.Make().Use(); } + public static void Member() { Cache.Shared.Touch(); } + public static object Plain() { return Cache.Get("y"); } + } +} From 8061569697f173f3b01755a7465db78cc312f411 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:35:44 -0700 Subject: [PATCH 24/40] impact: a Java type's initializer is run by whoever uses the type A call written in a field initializer has no method around it, so the engine names the type as its caller, and a static or instance block is a / callable. Nothing calls either, so the upward walk stopped there: a factory whose `INSTANCE = new Factory()` throws breaks every test that touches the factory, and none of them was selected. impact.dl now hops from such an initializer (a type reached as a caller, or a / ) to whoever uses the type in a way that runs it: a call to one of its members, a construction of a subtype, a read or write of one of its fields. The hop is taken only where the walk arrives at the type as a caller, never from a type or field target's own seed. Test routes through it carry a new rung, `at load`, ranked beside `at import`. The SQL fast path declines a closure that reaches such an initializer. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_evidence.py | 2 +- .../skills/axiomcode/scripts/axiomcode-impact | 6 ++--- .../skills/axiomcode/scripts/dl/impact.dl | 23 +++++++++++++++++++ .../skills/axiomcode/scripts/graph_sql.py | 18 +++++++++++++++ .../case.json | 18 +++++++++++++++ .../src/pkg/Boot.java | 7 ++++++ .../src/pkg/Cache.java | 7 ++++++ .../src/pkg/Client.java | 8 +++++++ .../src/pkg/ClientTest.java | 8 +++++++ .../src/pkg/Defaults.java | 15 ++++++++++++ .../src/pkg/Engine.java | 9 ++++++++ .../src/pkg/EngineTest.java | 8 +++++++ .../src/pkg/Orphan.java | 8 +++++++ .../src/pkg/OtherTest.java | 9 ++++++++ .../src/pkg/Registry.java | 12 ++++++++++ .../src/pkg/RegistryTest.java | 8 +++++++ .../src/pkg/Unused.java | 5 ++++ 17 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/case.json create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Boot.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Cache.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Client.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/ClientTest.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Defaults.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Engine.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/EngineTest.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Orphan.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/OtherTest.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Registry.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/RegistryTest.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Unused.java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py index 4a625ae25..89ca7349f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py @@ -34,7 +34,7 @@ # a certainty that is an exact edge (or not a row about a call at all): no evidence EXACT = {None, '', 'resolved', 'sound', 'entry', 'defines', 'defines (not a call)', 'must change', 'alongside', - 'stubs it', 'at import', 'decorator', 'test'} + 'stubs it', 'at import', 'at load', 'decorator', 'test'} def _env(k): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a8dc195f..c007571d8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2884,7 +2884,7 @@ def main(argv): # absorbed: a front end that gains a tier should show up here the first time anyone queries, not go # quietly into the bottom rung forever. UNKNOWN_TIERS = set() - CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'protocol': 'protocol', + CERT_UP = {'known_edge': 'sound', 'library': 'sound', 'written': 'sound', 'multi_inferred': 'one of a set', 'dispatch': 'dispatch', 'defines': 'defines', 'by key': 'by key', 'spawns': 'spawns', 'decorator': 'sound', 'decorator by name': 'decorator by name', 'at import': 'at import', 'at load': 'at load', 'protocol': 'protocol', # handed over as a value and invoked by whoever holds it (`callback_registered`), or emitted # here and handled there (`event_dispatch`). Real engine edges, so `by name` understates them; # registrations rather than resolutions, so `one of a set` — which claims a resolved target @@ -2941,7 +2941,7 @@ def main(argv): # is not the `fixture` rung it used to be labelled as — a fixture route is weak for exactly the reason this one # is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow # the word that means one. - CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, + CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'at load': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, 'by name': 12} # `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote @@ -3489,7 +3489,7 @@ def main(argv): # 180k answer on a hub method -- 435 tests, 433 of them through routes this answer itself calls weak -- which # no agent can read inline, so it was saved to a file and re-read piecemeal. Every test is still accounted for: # the strong ones by name here, all of them by rung and file under --tests, each route under --why. - STRONG = ('sound', 'defines', 'at import', 'one of a set') + STRONG = ('sound', 'defines', 'at import', 'at load', 'one of a set') tname = lambda m: f"{g.sym[m]['owner'] or os.path.basename(g.sym[m]['file'])}::{g.sym[m]['name']}" torder = lambda m: (tests[m][0], g.sym[m]['display'], g.loc(m), m) def route(m): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f8d70e364..7ca44f79e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1103,6 +1103,29 @@ constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), constructs(c, t), c != m. up(q, c, 1) :- protocol_hop(q, c, _). parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, protocol_hop(q, a, b). +// WHAT RUNS WHEN A TYPE IS LOADED OR BUILT (Java). A call written in a field initializer has no method around it, so +// the engine names the TYPE as its caller (call_chain.dl `call_from`), and a `static { }` / `{ }` block is a +// / callable. Nothing calls either: the JVM runs them when the type is first used or an +// instance is built. So the walk stopped at the type, and a factory whose `INSTANCE = new Factory()` throws — which +// breaks every test that touches the factory, by an ExceptionInInitializerError — selected none of them. +// The hop goes from that initializer to whoever uses the type in a way that runs it: calls one of its members (a +// static method, a constructor, a method of an instance), constructs a subtype, or reads or writes one of its fields. +// The type's own members count too: once its initialization has failed, every one of them fails with it. +// Taken only where the walk ARRIVES at the type as a caller (d > 0): a type that is the change itself, or the owner +// of a changed field, is a seed for other reasons, and its users are already the direct rows of those rules. +.decl init_runner(r:symbol, t:symbol) +init_runner(t, t) :- typ(t, _, _), java_decl(t), edge(t, _, _). +init_runner(c, t) :- member(t, c, "", _), java_decl(c). +init_runner(c, t) :- member(t, c, "", _), java_decl(c). +.decl init_use(a:symbol, t:symbol) +init_use(a, t) :- calls(a, m, tier, _, _), tier != "stub", owner(m, t). +init_use(a, t) :- calls(a, m, tier, _, _), tier != "stub", kind(m, "constructor"), owner(m, s), extends(s, t). +init_use(a, t) :- faccess(a, fl, _, _, _, _), field(fl, t, _, _, _). +.decl load_hop(q:symbol, a:symbol, r:symbol) +load_hop(q, a, r) :- up(q, r, d), d > 0, init_runner(r, t), r = t, init_use(a, t), a != r. +load_hop(q, a, r) :- up(q, r, _), init_runner(r, t), r != t, init_use(a, t), a != r. +up(q, a, d+1) :- up(q, r, d), load_hop(q, a, r), d < 40. +parent_up(q, a, b, "at load") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, load_hop(q, a, b). // WHAT RUNS AT IMPORT. `settings = Settings()` at the top level of a module is executed by the import statement, // so breaking Settings.__init__ breaks importing that module, and with it every module that imports it and every // test in those files. The graph records the module body's call to the constructor and records nothing at all about diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index ecf87c756..1e7d53e70 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -3571,6 +3571,21 @@ def _has_framework_hops(q, at=None, site_file=None): return True return False +def _reaches_init(q, depth): + """True when the closure holds a Java initializer the rules hop on from (load_hop): a type reached as a caller, or a + / callable""" + if not depth or not _has(q, 'symbols'): return False + ids = [m for m, d in depth.items() if d > 0 and m.startswith('TYPE_')] + if ids and q("SELECT 1 FROM call_edges WHERE caller_id IN ({}) LIMIT 1".format(','.join('?' * len(ids))), *ids): + return True + ids = list(depth) + for i in range(0, len(ids), 900): + part = ids[i:i + 900] + if q("SELECT 1 FROM symbols WHERE id IN ({}) AND name IN ('', '') AND file LIKE '%.java' LIMIT 1" + .format(','.join('?' * len(part))), *part): + return True + return False + def _spawn_edges(q, lines, at): """fw_edge(a, b, "spawns") :- spawns_fact(a, b): a test that runs a script by its path (ax_spawn.py)""" if lines is None or at is None or not _has(q, 'symbols'): return [] @@ -3868,6 +3883,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non out['direct_edge'] += [[c, m, qq] for c, m in _de] out['seed_byname'] += [[c, qq] for c in byname] depth = reach_from(rev, seeds, byname, gate=gate) + # the `at load` hop (dl/impact.dl load_hop) is not ported: a closure that arrives at a Java type as a caller + # (a field initializer) or at a / goes on to the type's users there, so decline + if _reaches_init(q, depth): return None out['reach'] += [[m, str(d), qq] for m, d in depth.items()] # reach_sure: the same closure from the seeds that are an exact edge only — a seed reached ONLY through a # by-name / text / one-of-a-set dependent is weak, and the answer says how much of itself rests on those diff --git a/tests/cases/java/type-initializer-runs-for-its-users/case.json b/tests/cases/java/type-initializer-runs-for-its-users/case.json new file mode 100644 index 000000000..211f16bc7 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/case.json @@ -0,0 +1,18 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a method called only from a static field initializer runs when the type is first used, so a test that calls a static method of that type is selected", + "run": ["impact", "Defaults.build", "--tests"], + "want": ["RegistryTest::findsKey"], + "avoid": ["OtherTest", "ClientTest", "0 of"]}, + {"why": "a method called from an instance field initializer runs in every construction, so a test that builds the type is selected", + "run": ["impact", "Cache.create", "--tests"], + "want": ["ClientTest::readsThroughTheCache"], + "avoid": ["RegistryTest", "EngineTest"]}, + {"why": "a method called from a static block runs when the type is first used", + "run": ["impact", "Boot.init", "--tests"], + "want": ["EngineTest::runsAfterBoot"], + "avoid": ["RegistryTest", "ClientTest"]}, + {"why": "control: the initializer of a type no test uses selects no test", + "run": ["impact", "Unused.compute", "--tests"], + "want": ["0 of"], + "avoid": ["RegistryTest", "ClientTest", "EngineTest", "OtherTest"]}]} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Boot.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Boot.java new file mode 100644 index 000000000..1a3d00c4d --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Boot.java @@ -0,0 +1,7 @@ +package pkg; + +public final class Boot { + static int ready; + + static void init() { ready = 1; } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Cache.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Cache.java new file mode 100644 index 000000000..2909d4243 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Cache.java @@ -0,0 +1,7 @@ +package pkg; + +public final class Cache { + static Cache create() { return new Cache(); } + + String get() { return "v"; } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Client.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Client.java new file mode 100644 index 000000000..6ec2d58a3 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Client.java @@ -0,0 +1,8 @@ +package pkg; + +public class Client { + // an instance field initializer runs in every constructor of Client + private final Cache cache = Cache.create(); + + public String read() { return cache.get(); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/ClientTest.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/ClientTest.java new file mode 100644 index 000000000..aab102ade --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/ClientTest.java @@ -0,0 +1,8 @@ +package pkg; + +import org.junit.Test; + +public class ClientTest { + @Test + public void readsThroughTheCache() { assert "v".equals(new Client().read()); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Defaults.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Defaults.java new file mode 100644 index 000000000..bf4490b54 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Defaults.java @@ -0,0 +1,15 @@ +package pkg; + +import java.util.HashMap; +import java.util.Map; + +public final class Defaults { + private Defaults() { } + + // runs only from Registry's static field initializer: no method calls it + static Map build() { + Map m = new HashMap<>(); + m.put("a", "1"); + return m; + } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Engine.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Engine.java new file mode 100644 index 000000000..2dc785067 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Engine.java @@ -0,0 +1,9 @@ +package pkg; + +public final class Engine { + static { + Boot.init(); + } + + public static int run() { return Boot.ready; } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/EngineTest.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/EngineTest.java new file mode 100644 index 000000000..f9b7cf8d4 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/EngineTest.java @@ -0,0 +1,8 @@ +package pkg; + +import org.junit.Test; + +public class EngineTest { + @Test + public void runsAfterBoot() { assert Engine.run() == 1; } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Orphan.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Orphan.java new file mode 100644 index 000000000..0626ac637 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Orphan.java @@ -0,0 +1,8 @@ +package pkg; + +// control: a static field initializer of a type no test touches selects no test +public final class Orphan { + static final int VALUE = Unused.compute(); + + static int value() { return VALUE; } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/OtherTest.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/OtherTest.java new file mode 100644 index 000000000..540b874a3 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/OtherTest.java @@ -0,0 +1,9 @@ +package pkg; + +import org.junit.Test; + +// control: touches none of the initialized types +public class OtherTest { + @Test + public void unrelated() { assert new Cache().get().equals("v"); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Registry.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Registry.java new file mode 100644 index 000000000..ff04aee9b --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Registry.java @@ -0,0 +1,12 @@ +package pkg; + +import java.util.Map; + +public final class Registry { + // the static initializer runs on the first use of Registry, so a throw in build() fails every user + static final Map TABLE = Defaults.build(); + + private Registry() { } + + public static String find(String key) { return TABLE.get(key); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/RegistryTest.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/RegistryTest.java new file mode 100644 index 000000000..610938711 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/RegistryTest.java @@ -0,0 +1,8 @@ +package pkg; + +import org.junit.Test; + +public class RegistryTest { + @Test + public void findsKey() { assert "1".equals(Registry.find("a")); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Unused.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Unused.java new file mode 100644 index 000000000..2ef247538 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Unused.java @@ -0,0 +1,5 @@ +package pkg; + +public final class Unused { + static int compute() { return 3; } +} From 34b9ebf8cb009822e3e2f1b7e17018d6f5177635 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:59:28 -0700 Subject: [PATCH 25/40] parser: a field initializer written after a comment is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `static final F X = // why new F() { ... };` — the Java field extractor took the node right after `=` as the initializer, and a comment is a named node in tree-sitter, so the whole initializer was dropped: its calls, and an anonymous class it creates together with that class's methods. A try-with-resources declaration had the same read. Both now skip comments, as a local variable's initializer already did. The comment-invariance twins gain a field initializer, a field holding an anonymous class and a resource declaration; the case checks that a test reaching the anonymous class's method through its interface is selected, beside the same shape with no comment. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../java/extractors/field-extractor.ts | 5 ++++- .../extractors/local-variable-extractor.ts | 3 ++- .../expressions/CommentsAreInvisible.java | 4 ++++ .../CommentsAreInvisibleControl.java | 4 ++++ .../case.json | 10 ++++++++++ .../src/pkg/Handler.java | 5 +++++ .../src/pkg/Handlers.java | 19 +++++++++++++++++++ .../src/pkg/Router.java | 5 +++++ .../src/pkg/RouterTest.java | 11 +++++++++++ .../src/pkg/Shout.java | 7 +++++++ 10 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/cases/java/field-initializer-after-a-comment/case.json create mode 100644 tests/cases/java/field-initializer-after-a-comment/src/pkg/Handler.java create mode 100644 tests/cases/java/field-initializer-after-a-comment/src/pkg/Handlers.java create mode 100644 tests/cases/java/field-initializer-after-a-comment/src/pkg/Router.java create mode 100644 tests/cases/java/field-initializer-after-a-comment/src/pkg/RouterTest.java create mode 100644 tests/cases/java/field-initializer-after-a-comment/src/pkg/Shout.java diff --git a/parser/src/parsers/java/extractors/field-extractor.ts b/parser/src/parsers/java/extractors/field-extractor.ts index 32dd5af9d..f312f5d0f 100644 --- a/parser/src/parsers/java/extractors/field-extractor.ts +++ b/parser/src/parsers/java/extractors/field-extractor.ts @@ -470,9 +470,12 @@ export class FieldExtractor { ); // Extract expressions from field initializer (if present) + // Skip comment nodes between = and the initializer, as a local variable's initializer does: + // `static final F X = // why\n new F() { ... };` dropped the whole initializer, anonymous class included const equalsIndex = declarator.children.findIndex(c => c.type === '='); if (equalsIndex >= 0 && equalsIndex < declarator.children.length - 1) { - const initializerNode = declarator.children[equalsIndex + 1]; + const initializerNode = declarator.children.slice(equalsIndex + 1) + .find(c => c.type !== 'line_comment' && c.type !== 'block_comment'); if (initializerNode) { const expressions = this.expressionExtractor.extractFromFieldInitializer( initializerNode, diff --git a/parser/src/parsers/java/extractors/local-variable-extractor.ts b/parser/src/parsers/java/extractors/local-variable-extractor.ts index 90254d5ec..53ac9021b 100644 --- a/parser/src/parsers/java/extractors/local-variable-extractor.ts +++ b/parser/src/parsers/java/extractors/local-variable-extractor.ts @@ -2525,7 +2525,8 @@ export class LocalVariableExtractor { // Extract initializer expressions const equalsIndex = child.children.findIndex(c => c.type === '='); if (equalsIndex >= 0 && equalsIndex < child.children.length - 1) { - const initializerNode = child.children[equalsIndex + 1]; + const initializerNode = child.children.slice(equalsIndex + 1) + .find(c => c.type !== 'line_comment' && c.type !== 'block_comment'); if (initializerNode) { this.extractInitializerExpressions( initializerNode, diff --git a/parser/src/test-data/java/expressions/CommentsAreInvisible.java b/parser/src/test-data/java/expressions/CommentsAreInvisible.java index 36c85d540..a3a0543dc 100644 --- a/parser/src/test-data/java/expressions/CommentsAreInvisible.java +++ b/parser/src/test-data/java/expressions/CommentsAreInvisible.java @@ -39,4 +39,8 @@ public class CommentsAreInvisible { void castExpression() { Object x = (Object) /* a */ f(); } void forClauses() { for (int i = /* a */ 0; /* b */ c; /* c */ i++) { } } int switchArm() { return switch (1) { default -> /* a */ f(); }; } + Object fieldInit = /* a */ new Object(); + Runnable fieldAnon = // a + new Runnable() { public void run() { f(); } }; + void tryResource() throws Exception { try (java.io.StringReader r = /* a */ new java.io.StringReader("")) { f(); } } } diff --git a/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java b/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java index d30a7754f..f7a61394b 100644 --- a/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java +++ b/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java @@ -25,4 +25,8 @@ public class CommentsAreInvisibleControl { void castExpression() { Object x = (Object) f(); } void forClauses() { for (int i = 0; c; i++) { } } int switchArm() { return switch (1) { default -> f(); }; } + Object fieldInit = new Object(); + Runnable fieldAnon = + new Runnable() { public void run() { f(); } }; + void tryResource() throws Exception { try (java.io.StringReader r = new java.io.StringReader("")) { f(); } } } diff --git a/tests/cases/java/field-initializer-after-a-comment/case.json b/tests/cases/java/field-initializer-after-a-comment/case.json new file mode 100644 index 000000000..a2c6c6b85 --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a field initializer written after a comment is still read: the anonymous class it creates, and what its method calls, are in the graph", + "run": ["impact", "Shout.loud", "--tests"], + "want": ["RouterTest::routesUpper"], + "avoid": ["0 of"]}, + {"why": "control: the same initializer with no comment, read before and after", + "run": ["impact", "Shout.soft", "--tests"], + "want": ["RouterTest::routesLower"], + "avoid": ["0 of"]}]} diff --git a/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handler.java b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handler.java new file mode 100644 index 000000000..129a14b75 --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handler.java @@ -0,0 +1,5 @@ +package pkg; + +public interface Handler { + String handle(String s); +} diff --git a/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handlers.java b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handlers.java new file mode 100644 index 000000000..cc5baf283 --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Handlers.java @@ -0,0 +1,19 @@ +package pkg; + +public final class Handlers { + private Handlers() { } + + public static final Handler UPPER = + // a comment between the `=` and the initializer, as a formatter or an author leaves one + new Handler() { + @Override + public String handle(String s) { return Shout.loud(s); } + }; + + // control: the same shape with no comment + public static final Handler LOWER = + new Handler() { + @Override + public String handle(String s) { return Shout.soft(s); } + }; +} diff --git a/tests/cases/java/field-initializer-after-a-comment/src/pkg/Router.java b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Router.java new file mode 100644 index 000000000..71e7e0d6c --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Router.java @@ -0,0 +1,5 @@ +package pkg; + +public class Router { + public String route(Handler h, String s) { return h.handle(s); } +} diff --git a/tests/cases/java/field-initializer-after-a-comment/src/pkg/RouterTest.java b/tests/cases/java/field-initializer-after-a-comment/src/pkg/RouterTest.java new file mode 100644 index 000000000..a9b06d235 --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/src/pkg/RouterTest.java @@ -0,0 +1,11 @@ +package pkg; + +import org.junit.Test; + +public class RouterTest { + @Test + public void routesUpper() { assert "X".equals(new Router().route(Handlers.UPPER, "x")); } + + @Test + public void routesLower() { assert "x".equals(new Router().route(Handlers.LOWER, "X")); } +} diff --git a/tests/cases/java/field-initializer-after-a-comment/src/pkg/Shout.java b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Shout.java new file mode 100644 index 000000000..914a7e27d --- /dev/null +++ b/tests/cases/java/field-initializer-after-a-comment/src/pkg/Shout.java @@ -0,0 +1,7 @@ +package pkg; + +public final class Shout { + static String loud(String s) { return s.toUpperCase(); } + + static String soft(String s) { return s.toLowerCase(); } +} From 7422e84790d3630c1ce5e7bc4d6902d7aec8743b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:09:46 -0700 Subject: [PATCH 26/40] impact: the at-load hop sees a test that reads a static field A method query is handed the field and field-access facts empty (KIND_SKIPS: only a field or type target was thought to reach them), so the `at load` hop's field clause derived nothing and a test that touches the initialized type only by reading one of its static fields (`Factory.INSTANCE_NULL.lookup(...)`) was dropped. Both relations are now loaded for the kinds that walk the closure; every other rule that joins them also needs a relation that stays skipped for those kinds, so nothing else changes. The case gains that shape. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 10 +- .../case.json | 101 +++++++++++++++--- .../src/pkg/Rows.java | 11 ++ .../src/pkg/Table.java | 10 ++ .../src/pkg/TableTest.java | 8 ++ 5 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Rows.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Table.java create mode 100644 tests/cases/java/type-initializer-runs-for-its-users/src/pkg/TableTest.java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index c007571d8..645e2e0f3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -129,10 +129,14 @@ _CONFIG = {'config', 'config_key_known', 'config_site'} # exactly the kind that used to be handed them empty. Skipping them there made the rule produce nothing at all, # silently — the one failure mode this table can have. _KEY_LAYER = {'literal', 'dec_literal'} +# and `field` / `faccess` are read by the `at load` hop for every kind that walks the closure: a test that reads a static +# field of a type runs that type's initializer (load_hop / init_use). Handed them empty, a method target's closure that +# reached a field initializer named only the type's CALLERS and dropped every test that touches it through a field. +_LOAD_LAYER = {'field', 'faccess'} KIND_SKIPS = { # relations a query of this kind can never reach - 'method': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, - 'param': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, - 'var': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, + 'method': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG, + 'param': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG, + 'var': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER - _LOAD_LAYER) | _CONFIG, 'decoration': (_REF_LAYER - _KEY_LAYER) | _CONFIG, 'config': _REF_LAYER - _KEY_LAYER, 'newconst': (_REF_LAYER - {'switch_over'} - _KEY_LAYER) | _CONFIG, diff --git a/tests/cases/java/type-initializer-runs-for-its-users/case.json b/tests/cases/java/type-initializer-runs-for-its-users/case.json index 211f16bc7..86364985e 100644 --- a/tests/cases/java/type-initializer-runs-for-its-users/case.json +++ b/tests/cases/java/type-initializer-runs-for-its-users/case.json @@ -1,18 +1,85 @@ -{"lang": "java", "src": "src", +{ + "lang": "java", + "src": "src", "checks": [ - {"why": "a method called only from a static field initializer runs when the type is first used, so a test that calls a static method of that type is selected", - "run": ["impact", "Defaults.build", "--tests"], - "want": ["RegistryTest::findsKey"], - "avoid": ["OtherTest", "ClientTest", "0 of"]}, - {"why": "a method called from an instance field initializer runs in every construction, so a test that builds the type is selected", - "run": ["impact", "Cache.create", "--tests"], - "want": ["ClientTest::readsThroughTheCache"], - "avoid": ["RegistryTest", "EngineTest"]}, - {"why": "a method called from a static block runs when the type is first used", - "run": ["impact", "Boot.init", "--tests"], - "want": ["EngineTest::runsAfterBoot"], - "avoid": ["RegistryTest", "ClientTest"]}, - {"why": "control: the initializer of a type no test uses selects no test", - "run": ["impact", "Unused.compute", "--tests"], - "want": ["0 of"], - "avoid": ["RegistryTest", "ClientTest", "EngineTest", "OtherTest"]}]} + { + "why": "a method called only from a static field initializer runs when the type is first used, so a test that calls a static method of that type is selected", + "run": [ + "impact", + "Defaults.build", + "--tests" + ], + "want": [ + "RegistryTest::findsKey" + ], + "avoid": [ + "OtherTest", + "ClientTest", + "0 of" + ] + }, + { + "why": "a method called from an instance field initializer runs in every construction, so a test that builds the type is selected", + "run": [ + "impact", + "Cache.create", + "--tests" + ], + "want": [ + "ClientTest::readsThroughTheCache" + ], + "avoid": [ + "RegistryTest", + "EngineTest" + ] + }, + { + "why": "a method called from a static block runs when the type is first used", + "run": [ + "impact", + "Boot.init", + "--tests" + ], + "want": [ + "EngineTest::runsAfterBoot" + ], + "avoid": [ + "RegistryTest", + "ClientTest" + ] + }, + { + "why": "a test that only READS a static field of the type runs its initializer too", + "run": [ + "impact", + "Rows.load", + "--tests" + ], + "want": [ + "TableTest::readsTheField" + ], + "avoid": [ + "RegistryTest", + "OtherTest", + "0 of" + ] + }, + { + "why": "control: the initializer of a type no test uses selects no test", + "run": [ + "impact", + "Unused.compute", + "--tests" + ], + "want": [ + "0 of" + ], + "avoid": [ + "RegistryTest", + "ClientTest", + "EngineTest", + "OtherTest" + ] + } + ] +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Rows.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Rows.java new file mode 100644 index 000000000..04a1e7b28 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Rows.java @@ -0,0 +1,11 @@ +package pkg; + +import java.util.Collections; +import java.util.Map; + +public final class Rows { + private Rows() { } + + // runs only from Table's static field initializer + static Map load() { return Collections.singletonMap("k", "v"); } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Table.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Table.java new file mode 100644 index 000000000..c472490d2 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/Table.java @@ -0,0 +1,10 @@ +package pkg; + +import java.util.Map; + +public final class Table { + // read directly by the test: reading a static field initializes the type first + public static final Map ROWS = Rows.load(); + + private Table() { } +} diff --git a/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/TableTest.java b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/TableTest.java new file mode 100644 index 000000000..2e94a7570 --- /dev/null +++ b/tests/cases/java/type-initializer-runs-for-its-users/src/pkg/TableTest.java @@ -0,0 +1,8 @@ +package pkg; + +import org.junit.Test; + +public class TableTest { + @Test + public void readsTheField() { assert "v".equals(Table.ROWS.get("k")); } +} From b73381cf4861c8960ba818ca0d9301eb6883ac3e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:09:47 -0700 Subject: [PATCH 27/40] impact: the members a library runs on a Java object reach whoever builds it `equals`, `hashCode` and `toString` are run by a HashMap key, a StringBuilder, String.format, an assertion; an override of a library method (`iterator` of an Iterable, `size` of a Collection, `close`) by the for-each loop, the collection, try-with-resources. The call is written on a library receiver or not at all, and Object's members are deliberately not fanned to client overrides by the engine, so a change to one had no caller and selected no test. The protocol hop Python already has (whoever constructs the type runs its protocol members) now has Java clauses: a member named equals / hashCode / toString, or one that overrides a library method, is reached by the callers of the type's constructors and of its subtypes' constructors. Still taken only from the change itself, under the `protocol` rung. The SQL fast path declines such a target. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/dl/impact.dl | 11 +++++++++++ .../skills/axiomcode/scripts/graph_sql.py | 14 ++++++++++++++ .../library-runs-the-members-it-is-given/case.json | 14 ++++++++++++++ .../lib/kit/src/kit/Pump.java | 8 ++++++++ .../lib/kit/src/kit/Source.java | 5 +++++ .../src/pkg/Index.java | 10 ++++++++++ .../src/pkg/IndexTest.java | 8 ++++++++ .../src/pkg/Key.java | 14 ++++++++++++++ .../src/pkg/OtherTest.java | 9 +++++++++ .../src/pkg/Tap.java | 8 ++++++++ .../src/pkg/TapTest.java | 9 +++++++++ .../src/pkg/Unseen.java | 7 +++++++ 12 files changed, 117 insertions(+) create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/case.json create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Pump.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Source.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Index.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/IndexTest.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Key.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/OtherTest.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Tap.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/TapTest.java create mode 100644 tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Unseen.java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 7ca44f79e..be879f518 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1094,6 +1094,17 @@ direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", protocol_member(t, m) :- member(t, m, n, _), match("__.*__", n), n != "__init__". .decl constructs(c:symbol, t:symbol) constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. +// THE SAME SHAPE IN JAVA: the members a LIBRARY runs on a client object. `equals`, `hashCode` and `toString` are run +// by a HashMap key, a StringBuilder, String.format, an assertion; an override of a library method (`iterator` of an +// Iterable, `compareTo`, `close`, `size` of a Collection) by the for-each loop, the sort, try-with-resources, the +// collection itself. The call is written on a library receiver or not written at all, and Object's members are not +// fanned to client overrides on purpose (resolution/virtual-dispatch.dl: the CHA blow-up), so a change to one had no +// caller and selected no test. Whoever constructs the type, or a subtype of it, holds the object those calls run on. +// Still only from the CHANGE itself, as above. +protocol_member(t, m) :- member(t, m, n, _), java_decl(m), (n = "equals" ; n = "hashCode" ; n = "toString"). +protocol_member(t, m) :- member(t, m, _, _), java_decl(m), override(b, m), !decl_file(b, _). +constructs(c, t) :- java_decl(t), kind(i, "constructor"), owner(i, t), edge(c, i, _), c != i. +constructs(c, t) :- java_decl(t), extends(s, t), kind(i, "constructor"), owner(i, s), edge(c, i, _), c != i. // The hop is taken only where the CHANGE ITSELF is a protocol member, not wherever one turns up in the closure. // As a global edge it was logically fine and empirically noisy: any closure that happened to contain a protocol // member pulled in every constructor of that type and, through them, their tests. Measured across three subjects diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 1e7d53e70..34521239c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -3571,6 +3571,17 @@ def _has_framework_hops(q, at=None, site_file=None): return True return False +def _java_protocol(q, mids): + """True when one of these Java methods is a member a library runs on the object (dl/impact.dl protocol_member)""" + if not mids or not _has(q, 'symbols'): return False + ph = ','.join('?' * len(mids)) + if q(f"SELECT 1 FROM symbols WHERE id IN ({ph}) AND file LIKE '%.java' AND name IN ('equals', 'hashCode', 'toString') LIMIT 1", *mids): + return True + return bool(_has(q, 'overrides') and q(f"""SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id + JOIN symbols s ON s.id = o.overriding_method_id + WHERE o.overriding_method_id IN ({ph}) AND b.provenance = 'lib' + AND s.file LIKE '%.java' LIMIT 1""", *mids)) + def _reaches_init(q, depth): """True when the closure holds a Java initializer the rules hop on from (load_hop): a type reached as a caller, or a / callable""" @@ -3650,6 +3661,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non # `target(q,k,s,x)`: a STRING target is written (q,"string","",value) — no symbol at all — so the ids and # the extras are kept apart rather than one standing in for the other. ids = sorted({s_ for _k, s_, _x in mine if s_}) + # a Java member a library runs (dl/impact.dl protocol_member: Object's equals / hashCode / toString, an + # override of a library method) is reached through whoever constructs its type: not ported, so decline + if _java_protocol(q, sorted({s_ for k_, s_, _x in mine if s_ and k_ == 'method'})): return None by_kind, extra = {}, {} for k, s_, x in mine: if s_: by_kind.setdefault(k, set()).add(s_) diff --git a/tests/cases/java/library-runs-the-members-it-is-given/case.json b/tests/cases/java/library-runs-the-members-it-is-given/case.json new file mode 100644 index 000000000..f4cf094fc --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/case.json @@ -0,0 +1,14 @@ +{"lang": "java", "src": "src", "library": "lib/kit", + "checks": [ + {"why": "hashCode is run by the map the key is put into; the test that builds the key through Index is selected", + "run": ["impact", "Key.hashCode", "--tests"], + "want": ["IndexTest::countsRepeats"], + "avoid": ["TapTest", "OtherTest", "0 of"]}, + {"why": "an override of a library method is run by the library; the test that builds the object and hands it over is selected", + "run": ["impact", "Tap.next", "--tests"], + "want": ["TapTest::drains"], + "avoid": ["IndexTest", "OtherTest", "0 of"]}, + {"why": "control: a toString on a type no test constructs selects no test", + "run": ["impact", "Unseen.toString", "--tests"], + "want": ["0 of"], + "avoid": ["IndexTest", "TapTest", "OtherTest"]}]} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Pump.java b/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Pump.java new file mode 100644 index 000000000..c034c91a7 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Pump.java @@ -0,0 +1,8 @@ +package kit; + +public final class Pump { + private Pump() { } + + // the library calls the client's override: no call to it is written in the client + public static String drain(Source s) { return s.next(); } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Source.java b/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Source.java new file mode 100644 index 000000000..6a92c4343 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/lib/kit/src/kit/Source.java @@ -0,0 +1,5 @@ +package kit; + +public interface Source { + String next(); +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Index.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Index.java new file mode 100644 index 000000000..9f726fd41 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Index.java @@ -0,0 +1,10 @@ +package pkg; + +import java.util.HashMap; +import java.util.Map; + +public class Index { + private final Map counts = new HashMap<>(); + + public int put(String name) { return counts.merge(new Key(name), 1, Integer::sum); } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/IndexTest.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/IndexTest.java new file mode 100644 index 000000000..5e3285936 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/IndexTest.java @@ -0,0 +1,8 @@ +package pkg; + +import org.junit.Test; + +public class IndexTest { + @Test + public void countsRepeats() { Index i = new Index(); i.put("a"); assert i.put("a") == 2; } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Key.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Key.java new file mode 100644 index 000000000..0d2a37960 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Key.java @@ -0,0 +1,14 @@ +package pkg; + +public final class Key { + private final String name; + + Key(String name) { this.name = name; } + + // run by the HashMap the key is put into: no call is written on a Key receiver + @Override + public int hashCode() { return name.hashCode(); } + + @Override + public boolean equals(Object o) { return o instanceof Key && ((Key) o).name.equals(name); } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/OtherTest.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/OtherTest.java new file mode 100644 index 000000000..ac5cfbd64 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/OtherTest.java @@ -0,0 +1,9 @@ +package pkg; + +import org.junit.Test; + +// control: constructs none of the types above +public class OtherTest { + @Test + public void unrelated() { assert "x".length() == 1; } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Tap.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Tap.java new file mode 100644 index 000000000..becee093c --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Tap.java @@ -0,0 +1,8 @@ +package pkg; + +import kit.Source; + +public class Tap implements Source { + @Override + public String next() { return "drip"; } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/TapTest.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/TapTest.java new file mode 100644 index 000000000..28864b46b --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/TapTest.java @@ -0,0 +1,9 @@ +package pkg; + +import kit.Pump; +import org.junit.Test; + +public class TapTest { + @Test + public void drains() { assert "drip".equals(Pump.drain(new Tap())); } +} diff --git a/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Unseen.java b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Unseen.java new file mode 100644 index 000000000..de93e1790 --- /dev/null +++ b/tests/cases/java/library-runs-the-members-it-is-given/src/pkg/Unseen.java @@ -0,0 +1,7 @@ +package pkg; + +// control: a type no test constructs +public final class Unseen { + @Override + public String toString() { return "unseen"; } +} From 5d153ad342a203228cbdfbef33bdaa999e4d65c1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:35 -0700 Subject: [PATCH 28/40] fix(parser/python): an import of a module shipped with a .pyi beside it resolves to the .py The project linker keys modules by qualified name, and a package that ships `impl.pyi` beside `impl.py` declares that name twice. The later file won the map, and `.pyi` sorts after `.py`, so `from .impl import *` and `import pkg` resolved to the stub. The engine refuses a stub declaration as an edge target, so every call into such a package ended at the library boundary and its tests reached nothing in it. A stub no longer replaces a non-stub module of the same name. A stub with no `.py` beside it is still the import's target. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../parsers/python/extractors/python-resolution-linker.ts | 8 ++++++++ parser/src/workflows/python/python-project-analyzer.ts | 1 + .../stub-beside-source-resolves-to-source/case.json | 8 ++++++++ .../stub-beside-source-resolves-to-source/pkg/__init__.py | 1 + .../pkg/__init__.pyi | 1 + .../stub-beside-source-resolves-to-source/pkg/impl.py | 5 +++++ .../stub-beside-source-resolves-to-source/pkg/impl.pyi | 1 + .../stub-beside-source-resolves-to-source/pyproject.toml | 3 +++ .../tests/__init__.py | 0 .../tests/test_chunk.py | 5 +++++ 10 files changed, 33 insertions(+) create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/case.json create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c54857..71d448e58 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a5..0ec56d2bf 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 000000000..97a74a7fb --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 000000000..66c63213a --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 000000000..475cf4eac --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 000000000..79628415b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 000000000..3f70ad893 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 000000000..c311e64bc --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 000000000..c459b14b6 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]] From ab1e09be387cf24e7698281319eb08a82c5a25e3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:03:03 -0700 Subject: [PATCH 29/40] fix(impact): a test that spawns `python -m pkg` runs pkg/__main__.py The spawn link (a test that starts a process whose arguments name an indexed file) only read PATHS. `subprocess.run([sys.executable, "-m", "pkg", ...])` names a MODULE: Python runs pkg/__main__.py (or pkg/mod.py for `-m pkg.mod`) as __main__, so everything the package's entry reaches was untested from that test's point of view, and a CLI suite that drives its tool this way selected nothing for an edit to the tool's internals. ax_spawn now resolves the name after each `-m` like an import: from the repository root or a source root (a directory that is no package and no test directory), package __main__ first, and only to a file this graph indexed. `-m coverage run -m pkg` names two modules; coverage is not indexed and links nothing. A string that only mentions `-m pkg` starts no process and stays unlinked. IMPACT_VERSION 66 -> 67 (spawns_fact changed). Measured with the mutation oracle on sixteen public repositories: the one whose CLI tests spawn `-m ` moves test-selection recall 0.422 -> 0.468 (+175 failing test files selected, none lost, 121 added files that do not fail); a second repository's `python -m ` spawn links but its targets sit behind a protocol call, so it does not move; no other repository spawns `-m`. Case test-runs-a-module-with-dash-m fails 3 of 4 checks on the base. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_spawn.py | 34 +++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../test-runs-a-module-with-dash-m/case.json | 68 +++++++++++++++++++ .../pkg/__init__.py | 0 .../pkg/__main__.py | 3 + .../test-runs-a-module-with-dash-m/pkg/cli.py | 5 ++ .../pkg/core.py | 6 ++ .../pkg/tool.py | 6 ++ .../tests/test_docs.py | 5 ++ .../tests/test_entry_points.py | 8 +++ .../tests/test_package_main.py | 7 ++ .../tests/test_tally.py | 5 ++ 12 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/case.json create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py index f26ed10c1..f15e6c4a7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py @@ -10,6 +10,10 @@ starts no process and is not linked; a path naming a file of another language is in no graph of this language and is not linked either (a spawn across languages is not built here). +A Python spawn can name a MODULE instead of a path: `[sys.executable, "-m", "pkg"]` runs pkg/__main__.py, and +`-m pkg.tool` runs pkg/tool.py, as __main__ either way. The name after `-m` is resolved like an import, from the +repository root or a source root, and only to a file this graph indexed (`-m pytest`, `-m coverage` name none). + Both backends read this module (dl/impact.dl through the `spawns_fact` input, graph_sql through its edge list), so they cannot drift apart on what counts as a spawn. """ @@ -119,6 +123,32 @@ def _resolve(lits, f, known): return out +MODULE = re.compile(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*') +TEST_DIR = {'test', 'tests', 'testing'} + + +def _resolve_modules(lits, known): + """the indexed files `python -m pkg.mod` runs: pkg/mod/__main__.py for a package, else pkg/mod.py. + + The name is a MODULE, found on the import path, so it is matched from the repository root or from a source root + (`src/`): a directory that is no package itself and no test directory. `-m coverage run -m typer` names two + modules; the one this graph indexed is the one linked, and one it did not index (coverage, pytest) names none.""" + out = set() + for i in range(len(lits) - 1): + if lits[i] != '-m' or not MODULE.fullmatch(lits[i + 1]): continue + rel = lits[i + 1].replace('.', '/') + for cand in (rel + '/__main__.py', rel + '.py'): + hits = [] + for p in known: + if p != cand and not p.endswith('/' + cand): continue + root = p[:len(p) - len(cand)].rstrip('/') + if root and (root + '/__init__.py' in known or TEST_DIR & set(root.split('/'))): continue + hits.append((len(root), p)) + if hits: + out.add(min(hits)[1]); break + return out + + def links(test_files, mod_of, lines, at): """-> sorted [(caller, script_module, file, line)]: in a test file, a spawn whose arguments name an indexed script. @@ -140,7 +170,9 @@ def links(test_files, mod_of, lines, at): and text[max(0, m.start() - 40):m.start()].rstrip()[-1:] != '.'] for end in sorted(set(sites)): arg = _balanced(text, end) - targets = _resolve(_literals(text, arg), f, known) + lits = _literals(text, arg) + targets = _resolve(lits, f, known) + if fam == 'py': targets |= _resolve_modules(lits, known) if not targets: continue line = text.count('\n', 0, end) + 1 c = at(f, line) or mod_of.get(f) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 75338e6d8..eee283098 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1339,7 +1339,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '67' # 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/case.json b/tests/cases/python/test-runs-a-module-with-dash-m/case.json new file mode 100644 index 000000000..01e3c4ec8 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/case.json @@ -0,0 +1,68 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`python -m pkg` runs pkg/__main__.py: a test that spawns it covers what the package's main reaches, though nothing imports pkg.cli", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_package_main.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_tally.py" + ] + }, + { + "why": "`-m coverage run -m pkg.tool` names two modules: coverage is not in this graph, pkg.tool is pkg/tool.py, run as __main__ so its main guard is taken", + "run": [ + "impact", + "go", + "--tests" + ], + "want": [ + "test_entry_points.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_package_main.py" + ] + }, + { + "why": "CONTROL: a string that only mentions `-m pkg` starts no process and links nothing; a function the module does not reach keeps exactly the test that calls it", + "run": [ + "impact", + "tally", + "--tests" + ], + "want": [ + "test_tally.py" + ], + "avoid": [ + "test_package_main.py", + "test_docs.py", + "spawns" + ] + }, + { + "why": "test-impact for an edit to a module the package main reaches selects the spawning test and not the one that only names the module", + "run": [ + "test-impact", + "{repo}", + "pkg/cli.py" + ], + "want": [ + "tests/test_package_main.py" + ], + "avoid": [ + "tests/test_docs.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py new file mode 100644 index 000000000..4e28416e1 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py new file mode 100644 index 000000000..9bcd4f9df --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py @@ -0,0 +1,5 @@ +from .core import render + + +def main(): + print(render("cli")) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py new file mode 100644 index 000000000..73d21203a --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py @@ -0,0 +1,6 @@ +def render(name): + return f"<{name}>" + + +def tally(xs): + return len(xs) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py new file mode 100644 index 000000000..ac2490b2a --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py @@ -0,0 +1,6 @@ +def go(): + return "go" + + +if __name__ == "__main__": + print(go()) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py new file mode 100644 index 000000000..ac86725ef --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py @@ -0,0 +1,5 @@ +USAGE = "run it with: python -m pkg" + + +def test_usage_mentions_the_module(): + assert "-m pkg" in USAGE diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py new file mode 100644 index 000000000..dcd4991ce --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py @@ -0,0 +1,8 @@ +import subprocess +import sys + +CMD = [sys.executable, "-m", "coverage", "run", "-m", "pkg.tool"] + + +def test_tool_runs(): + assert subprocess.run(CMD, capture_output=True).returncode == 0 diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py new file mode 100644 index 000000000..0654d6448 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py @@ -0,0 +1,7 @@ +import subprocess +import sys + + +def test_module_runs(): + out = subprocess.run([sys.executable, "-m", "pkg", "--help"], capture_output=True, text=True).stdout + assert out diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py new file mode 100644 index 000000000..4f0bf0c8b --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py @@ -0,0 +1,5 @@ +from pkg.core import tally + + +def test_tally(): + assert tally([1, 2]) == 2 From d202458e03ea4a5d6df116aebbd5ad2f7008ae1a Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:10:59 -0700 Subject: [PATCH 30/40] fix(python): getattr(self, f"visit_{...}") dispatches to the visit_* members of self's constructed subclasses Visitor and parser dispatch by a computed name -- `return getattr(self, f"visit_{type(node).__name__}", None)` in a base class, then `f = self.get_visitor(node); f(node)` -- had no edges at all, so every visit_* body was cut off from whoever drives the visitor. Three gaps stacked: - the parser gave an f-string an empty literal value. It now carries the f-string's constant lead (the text before its first `{`), which every string it can produce starts with; nothing else reads an FSTRING literal. - the existing prefix rule (`getattr(obj, "as_" + x)`) read only a `+` concatenation and only obj's own MRO. It now also reads an f-string lead, and on `self` it widens over the CONSTRUCTED subclasses, the same RTA bound self.m() has; a decorated member resolves to the wrapper its decorator returned. - a method returning such a lookup hands the set back (method_returns_method), as it would a bare name. The set is the program's own dispatch table, so the dispatch cap no longer refuses it (call_computed_name_family): a visitor with sixty members is sixty targets by construction, and the cap had dropped the whole site. Mutation oracle, sixteen public repositories (ten tuning, six held out): the visitor-heavy tuning repository moves test-selection recall 0.474 -> 0.878 (+169 failing test files selected, none lost; 203 added files do not fail, the same 0.45 precision as its base); one held-out repository +2; every other repository unchanged. Case getattr-fstring-prefix-dispatch fails 3 of 4 checks on the base (2 of 4 with the f-string rule but without the cap exemption). value-callee-is-unknown's computed-name check moves to a name with no written part (`getattr(self, event)`), which stays an unknown: the f-string form it used now resolves, as its `+` control already did. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/call_chain.dl | 9 ++ graph/python/engine/resolution/dispatch.dl | 26 ++++- .../engine/resolution/name-resolution.dl | 27 +++++- graph/python/souffle/decls_all.dl | 5 + .../extractors/python-expression-extractor.ts | 41 +++++++- .../app/__init__.py | 0 .../app/compiler.py | 97 +++++++++++++++++++ .../app/nodes.py | 6 ++ .../app/parser.py | 14 +++ .../app/visitor.py | 22 +++++ .../getattr-fstring-prefix-dispatch/case.json | 60 ++++++++++++ .../tests/test_generate.py | 6 ++ .../tests/test_leave.py | 6 ++ .../tests/test_parse.py | 5 + .../python/value-callee-is-unknown/case.json | 2 +- .../value-callee-is-unknown/src/handlers.py | 2 +- 16 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/case.json create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index 6c35b6cf3..ac802b6ac 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -156,6 +156,15 @@ method_returns_method("client", m, r) :- expr_call_candidate(site, g), method_returns_method("client", g, r). +// A function returning a member it LOOKED UP BY NAME: `def get_visitor(self, node): return +// getattr(self, f"visit_{type(node).__name__}", None)`, called as `f = self.get_visitor(node); +// f(node)`. The lookup is resolved where it is written (name-resolution.dl's getattr clauses), +// and the method hands that set back exactly as it would hand back a bare name. +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), + call_of_expr(e, site), call_name(site, "getattr"), + expr_denotes_method("client", e, r). + // ── a call that hands back one of its arguments (py_returns_arg, resolution/builtins.dl) ── // Matched through the import that binds the callee, never by the bare name: // `functools.update_wrapper(...)` / `t.cast(...)` where the receiver is the name an `import` diff --git a/graph/python/engine/resolution/dispatch.dl b/graph/python/engine/resolution/dispatch.dl index f1f50d216..ed56e8ccf 100644 --- a/graph/python/engine/resolution/dispatch.dl +++ b/graph/python/engine/resolution/dispatch.dl @@ -142,7 +142,31 @@ call_candidate_count(site, n) :- // ── call_dispatch_over_cap(SiteHash) ───────────────────────────────────────── call_dispatch_over_cap(site) :- - call_candidate_count(site, n), dispatch_capped_at(cap), n > cap. + call_candidate_count(site, n), dispatch_capped_at(cap), n > cap, + !call_computed_name_family(site). + +// ── call_computed_name_family(SiteHash) — a fan the PROGRAM wrote down ─────── +// The cap guards against a receiver type inferred too wide, where most of the fan is the +// engine's imprecision. A callee looked up by a name with a written prefix -- +// `getattr(self, f"visit_{...}")`, directly, through the local it was stored in, or +// through a method that returns it -- is the program's own dispatch table: every member +// named with the prefix is a target by construction, and a visitor with sixty visit_* +// methods is sixty targets, not sixty guesses. Refusing it dropped the visitor's whole +// body from every test that drives it. +method_returns_computed_name(m) :- + method_return_value_expr("client", m, e), getattr_computed_name(e, _, _). +computed_name_value(e) :- getattr_computed_name(e, _, _). +computed_name_value(e) :- + call_of_expr(e, s), expr_call_candidate(s, m), method_returns_computed_name(m). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), computed_name_value(callee). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, ctx, callee), ctx != "STORE", + binding_lookup("client", b, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + computed_name_value(val). // ── expr_resolves_to_method(SiteHash, MethodHash) ──────────────────────────── // THE DISAMBIGUATED CALLEE SET the edge layer consumes. Same relation name as Java's, diff --git a/graph/python/engine/resolution/name-resolution.dl b/graph/python/engine/resolution/name-resolution.dl index 308884848..b9345eb12 100644 --- a/graph/python/engine/resolution/name-resolution.dl +++ b/graph/python/engine/resolution/name-resolution.dl @@ -1008,18 +1008,41 @@ computed_name_prefix(nameArg, pre) :- expr_operator("client", op, nameArg), py_str_concat_operator(op), expr_parent("client", nameArg, "OPERAND_LEFT", _, l), expr_node("client", "LITERAL", _, pre, l), pre != "". +// The same prefix written as an f-string, `getattr(self, f"visit_{type(node).__name__}")`: +// the parser hands over an f-string's constant lead (the text before its first `{`) as its +// literal value, and every string the f-string can produce starts with it. +computed_name_prefix(nameArg, pre) :- + expr_node("client", "FSTRING", _, pre, nameArg), pre != "". getattr_computed_name(e, obj, pre) :- call_of_expr(e, site), call_name(site, "getattr"), call_arg(site, "0", obj), call_arg(site, "1", nameArg), computed_name_prefix(nameArg, pre). -expr_denotes_method("client", e, m) :- +// The receiver's type, and where the receiver is `self`, every CONSTRUCTED subclass of it too: +// a visitor base names `visit_*` members it does not define itself, and self is whichever +// subclass was built -- the same self-dispatch bound (RTA) dispatch.dl puts on `self.m()`. +getattr_computed_type(e, pre, t) :- getattr_computed_name(e, obj, pre), - expr_type("client", obj, t), + expr_type("client", obj, t). +getattr_computed_type(e, pre, s) :- + getattr_computed_name(e, obj, pre), + expr_node("client", "SELF_REFERENCE", _, _, obj), + expr_type("client", obj, k), + type_subclass("client", k, s), + type_instantiated_reachable("client", s). +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), mro_lookup("client", t, n, m), strlen(n) > strlen(pre), substr(n, 0, strlen(pre)) = pre. +// a member a decorator replaced is the callable the decorator returned (a wrapper) +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), + mro_lookup_decorated("client", t, n, m), + strlen(n) > strlen(pre), + substr(n, 0, strlen(pre)) = pre. +expr_denotes_method("client", e, m) :- getattr_computed_member(e, m). // ── getattr(obj, name, default) IS AT LEAST the default ────────────────────── // h = getattr(record, "highlighter", self.highlighter) diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 6f1fd8647..44d353f59 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -534,6 +534,8 @@ .decl expr_operator(c0:symbol,c1:symbol,c2:symbol) .decl local_holds_untyped_return(c0:symbol,c1:symbol) .decl getattr_computed_name(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_type(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_member(c0:symbol,c1:symbol) .decl method_is_async(c0:symbol,c1:symbol) .decl await_operand(c0:symbol,c1:symbol,c2:symbol) .decl subscript_index_is_slice(c0:symbol,c1:symbol) @@ -668,3 +670,6 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl method_returns_computed_name(c0:symbol) +.decl computed_name_value(c0:symbol) +.decl call_computed_name_family(c0:symbol) diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts index d6a8763d9..3db5d6228 100644 --- a/parser/src/parsers/python/extractors/python-expression-extractor.ts +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -1580,7 +1580,11 @@ export class PythonExpressionExtractor { return; } - case PythonExpressionKind.FSTRING: + case PythonExpressionKind.FSTRING: { + builder.withLiteral(PythonLiteralType.FSTRING, this.fstringPrefixOf(node)); + return; + } + case PythonExpressionKind.FSTRING_INTERPOLATION: { builder.withLiteral(PythonLiteralType.FSTRING, ''); return; @@ -3077,6 +3081,41 @@ export class PythonExpressionExtractor { return EntityUtils.normalizeWhitespace(parts.join('')); } + /** + * An f-string's CONSTANT LEAD: the text written before its first `{...}`. + * `f"visit_{type(node).__name__}"` evaluates to some string starting with + * `visit_`, whatever the interpolation yields, so the lead is a fact about + * every value it can produce. That is what `getattr(self, f"visit_{...}")` + * dispatch needs: the candidates are exactly the members named with that + * prefix. Text after the first interpolation is not a prefix of anything and + * is left out. Empty when the f-string opens with an interpolation. + */ + private fstringPrefixOf(node: Parser.SyntaxNode): string { + const parts: string[] = []; + let done = false; + const collect = (current: Parser.SyntaxNode): void => { + for (let index = 0; index < current.namedChildCount && !done; index += 1) { + const part = current.namedChild(index); + if (!part) { + continue; + } + if (part.type === 'interpolation') { + done = true; + return; + } + if (part.type === 'string_content') { + parts.push(part.text); + continue; + } + if (part.type === 'string' || part.type === 'concatenated_string') { + collect(part); + } + } + }; + collect(node); + return EntityUtils.normalizeWhitespace(parts.join('')); + } + private binaryOperatorOf(node: Parser.SyntaxNode): string { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py new file mode 100644 index 000000000..e12ac85d9 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py @@ -0,0 +1,97 @@ +from .visitor import NodeVisitor, traced + + +def emit_name(): + return "name" + + +def emit_const(): + return "const" + + +def helper_not_visited(): + return "other" + + +class CodeGenerator(NodeVisitor): + def visit_Name(self, node): + return emit_name() + + @traced + def visit_Const(self, node): + return emit_const() + + def visit_Kind0(self, node): + return 0 + + def visit_Kind1(self, node): + return 1 + + def visit_Kind2(self, node): + return 2 + + def visit_Kind3(self, node): + return 3 + + def visit_Kind4(self, node): + return 4 + + def visit_Kind5(self, node): + return 5 + + def visit_Kind6(self, node): + return 6 + + def visit_Kind7(self, node): + return 7 + + def visit_Kind8(self, node): + return 8 + + def visit_Kind9(self, node): + return 9 + + def visit_Kind10(self, node): + return 10 + + def visit_Kind11(self, node): + return 11 + + def visit_Kind12(self, node): + return 12 + + def visit_Kind13(self, node): + return 13 + + def visit_Kind14(self, node): + return 14 + + def visit_Kind15(self, node): + return 15 + + def visit_Kind16(self, node): + return 16 + + def visit_Kind17(self, node): + return 17 + + def visit_Kind18(self, node): + return 18 + + def visit_Kind19(self, node): + return 19 + + def visit_Kind20(self, node): + return 20 + + def visit_Kind21(self, node): + return 21 + + def visit_Kind22(self, node): + return 22 + + def visit_Kind23(self, node): + return 23 + + def leave_Name(self, node): + return helper_not_visited() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py new file mode 100644 index 000000000..9800e3981 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py @@ -0,0 +1,6 @@ +class Name: + pass + + +class Const: + pass diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py new file mode 100644 index 000000000..4984ddfe9 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py @@ -0,0 +1,14 @@ +def parse_if_body(): + return "if" + + +class Parser: + def __init__(self, keyword): + self.keyword = keyword + + def parse_statement(self): + f = getattr(self, f"parse_{self.keyword}") + return f() + + def parse_if(self): + return parse_if_body() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py new file mode 100644 index 000000000..4b718a1a3 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py @@ -0,0 +1,22 @@ +from functools import update_wrapper + + +class NodeVisitor: + def get_visitor(self, node): + return getattr(self, f"visit_{type(node).__name__}", None) + + def visit(self, node): + f = self.get_visitor(node) + if f is not None: + return f(node) + return self.generic_visit(node) + + def generic_visit(self, node): + return None + + +def traced(f): + def new_func(self, node): + return f(self, node) + + return update_wrapper(new_func, f) diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/case.json b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json new file mode 100644 index 000000000..b9dadc6d2 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json @@ -0,0 +1,60 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a visitor base's get_visitor returns getattr(self, f\"visit_{...}\"): visit() reaches every visit_* member of a constructed subclass -- 26 of them, past the dispatch cap of 20, because a prefix family is the program's own dispatch table -- so a test driving visit() covers what visit_Name calls", + "run": [ + "impact", + "emit_name", + "--tests" + ], + "want": [ + "test_generate.py" + ], + "avoid": [ + "test_parse.py" + ] + }, + { + "why": "a visit_* member a decorator replaced is reached through the wrapper the decorator returned", + "run": [ + "impact", + "emit_const", + "--tests" + ], + "want": [ + "test_generate.py" + ] + }, + { + "why": "the f-string prefix held in a local and called (`f = getattr(self, f\"parse_{kw}\"); f()`)", + "run": [ + "impact", + "parse_if_body", + "--tests" + ], + "want": [ + "test_parse.py" + ], + "avoid": [ + "test_generate.py" + ] + }, + { + "why": "CONTROL: a member whose name does not start with the prefix (leave_Name) is not a visit_ candidate: only the test calling it directly", + "run": [ + "impact", + "helper_not_visited", + "--tests" + ], + "want": [ + "test_leave.py" + ], + "avoid": [ + "test_generate.py", + "test_parse.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py new file mode 100644 index 000000000..a9d88382b --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_generate_name(): + assert CodeGenerator().visit(Name()) == "name" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py new file mode 100644 index 000000000..f7c144972 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_leave(): + assert CodeGenerator().leave_Name(Name()) == "other" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py new file mode 100644 index 000000000..7a4fa3827 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py @@ -0,0 +1,5 @@ +from app.parser import Parser + + +def test_parse_if(): + assert Parser("if").parse_statement() == "if" diff --git a/tests/cases/python/value-callee-is-unknown/case.json b/tests/cases/python/value-callee-is-unknown/case.json index 7d63b1c5d..1d36b13c6 100644 --- a/tests/cases/python/value-callee-is-unknown/case.json +++ b/tests/cases/python/value-callee-is-unknown/case.json @@ -67,7 +67,7 @@ ] }, { - "why": "fire calls a local holding getattr(self, f'on_{event}'): the site is named, and the target is not called independent", + "why": "fire calls a local holding getattr(self, event): no part of the name is written, so the site is named and the target is not called independent (an f-string or `+` prefix resolves; see getattr-fstring-prefix-dispatch)", "run": [ "path", "Machine.fire", diff --git a/tests/cases/python/value-callee-is-unknown/src/handlers.py b/tests/cases/python/value-callee-is-unknown/src/handlers.py index 586521f51..703cac059 100644 --- a/tests/cases/python/value-callee-is-unknown/src/handlers.py +++ b/tests/cases/python/value-callee-is-unknown/src/handlers.py @@ -39,7 +39,7 @@ def via_local(name): class Machine: def fire(self, event): - hook = getattr(self, f"on_{event}", None) + hook = getattr(self, event, None) if hook is not None: hook() From ee7d906b38db69bc9e28f6096689a0f824d32387 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:47 -0700 Subject: [PATCH 31/40] fix(python): `with X() as y` types y as X where X.__enter__ returns self without an annotation with_target_declared typed a `with ... as` target from __enter__'s declared return or a literal `Self`. An unannotated `def __enter__(self): return self` -- the commonest way to write it -- typed nothing, so `with Session() as s: s.request(...)` was an untyped receiver and everything the session does was cut off from the code that opens one. Five such __enter__ definitions in three of sixteen public repositories. The written return is now read: no annotation and a return value that is `self` binds the target to the context manager's own type. An __enter__ that returns something else is untouched (control in the case). Mutation oracle, sixteen repositories: one tuning repository +22 failing test files selected (14 added files do not fail), none lost; all others unchanged. Case with-enter-returns-self fails its first check on the base. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/iteration.dl | 12 ++++++ .../with-enter-returns-self/app/__init__.py | 0 .../with-enter-returns-self/app/session.py | 38 +++++++++++++++++++ .../python/with-enter-returns-self/case.json | 30 +++++++++++++++ .../tests/test_fetch.py | 5 +++ .../tests/test_pooled.py | 5 +++ 6 files changed, 90 insertions(+) create mode 100644 tests/cases/python/with-enter-returns-self/app/__init__.py create mode 100644 tests/cases/python/with-enter-returns-self/app/session.py create mode 100644 tests/cases/python/with-enter-returns-self/case.json create mode 100644 tests/cases/python/with-enter-returns-self/tests/test_fetch.py create mode 100644 tests/cases/python/with-enter-returns-self/tests/test_pooled.py diff --git a/graph/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl index 7a3e32794..374cc9f69 100644 --- a/graph/python/engine/resolution/iteration.dl +++ b/graph/python/engine/resolution/iteration.dl @@ -98,6 +98,18 @@ with_target_declared(p, tgt, ct) :- method_decl(p, _, _, _, ret, m), self_return_name(ret). +// The same, WRITTEN rather than declared: an __enter__ with no return annotation whose +// return value is `self` (`def __enter__(self): return self`). The protocol hands the +// target what __enter__ returned, and that is the context manager itself. +with_target_declared(p, tgt, ct) :- + with_pair(p, tgt, cm), + expr_root_context(p, crc, cm), context_manager_enter_of(crc, en), + expr_type(p, cm, ct), + mro_lookup(p, ct, en, m), + !method_declared_return_type(p, m, _), + method_return_value_expr(p, m, e), + expr_node(p, "SELF_REFERENCE", _, _, e). + // AN __enter__ RETURN IS A DECLARED TYPE, not an exact one, so it goes out through // declared_dispatch and is registered with call_recv_declared in dispatch.dl. A declared // type escaping that gate has produced a confident-wrong answer four times in this engine. diff --git a/tests/cases/python/with-enter-returns-self/app/__init__.py b/tests/cases/python/with-enter-returns-self/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/with-enter-returns-self/app/session.py b/tests/cases/python/with-enter-returns-self/app/session.py new file mode 100644 index 000000000..8e01cd891 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/app/session.py @@ -0,0 +1,38 @@ +def send_bytes(): + return b"sent" + + +def open_raw(): + return "raw" + + +class Session: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +class Pool: + def __enter__(self): + return open_raw() + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +def fetch(url): + with Session() as session: + return session.request(url) + + +def fetch_pooled(url): + with Pool() as conn: + return conn.request(url) diff --git a/tests/cases/python/with-enter-returns-self/case.json b/tests/cases/python/with-enter-returns-self/case.json new file mode 100644 index 000000000..e94fc8550 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/case.json @@ -0,0 +1,30 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`with Session() as session` where Session.__enter__ is unannotated and returns self: session IS the Session, so session.request() resolves", + "run": [ + "impact", + "send_bytes", + "--tests" + ], + "want": [ + "test_fetch.py" + ] + }, + { + "why": "CONTROL: an unannotated __enter__ that returns something else does not type its target as the context manager: conn.request() stays a by-name match, never a resolved call", + "run": [ + "impact", + "Pool.request" + ], + "want": [ + "[by name] fetch_pooled" + ], + "avoid": [ + "[resolved] fetch_pooled" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/with-enter-returns-self/tests/test_fetch.py b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py new file mode 100644 index 000000000..4ac13099e --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py @@ -0,0 +1,5 @@ +from app.session import fetch + + +def test_fetch(): + assert fetch("u") == b"sent" diff --git a/tests/cases/python/with-enter-returns-self/tests/test_pooled.py b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py new file mode 100644 index 000000000..a43b88e1f --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py @@ -0,0 +1,5 @@ +from app.session import fetch_pooled + + +def test_pooled(): + fetch_pooled("u") From b836d07518aedea9ca42d1e25384f63bb418dfd6 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:47 -0700 Subject: [PATCH 32/40] fix(python): a loop over a mapping's .items()/.values()/.keys() types its targets from the annotation `for name, field in self.fields.items()` with `fields: dict[str, Field]` bound nothing: no element rule looked through a view call, and an items() loop has a TUPLE target that no rule bound. About 80 loops over an annotated mapping's view in eleven of sixteen public repositories. mapping_annotation_of finds the container annotation an expression carries (a field through self or a typed object, a parameter, a callee's declared return, a single-write local holding one); the slots are read POSITIONALLY (key at generic position 0, value at 1) rather than through type_ref_element, which merges both. values()/keys() yield one slot; items() binds each name of a tuple target to its own slot. The view names are catalogued in builtins.dl. Mutation oracle, sixteen repositories: one tuning repository +11 failing test files selected (7 added do not fail), none lost; path found on tuning 0.756 -> 0.764. Case dict-view-loop-types-the-value fails 3 of 4 checks on the base; its control (a key slot is never the value type) passes on both. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/builtins.dl | 10 +++ graph/python/engine/resolution/iteration.dl | 73 +++++++++++++++++++ graph/python/souffle/decls_all.dl | 7 ++ .../app/__init__.py | 0 .../app/schema.py | 55 ++++++++++++++ .../dict-view-loop-types-the-value/case.json | 17 +++++ .../tests/test_dump.py | 5 ++ .../tests/test_keys.py | 9 +++ .../tests/test_load.py | 5 ++ 9 files changed, 181 insertions(+) create mode 100644 tests/cases/python/dict-view-loop-types-the-value/app/__init__.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/app/schema.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/case.json create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index 02da01634..755133bbc 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -370,3 +370,13 @@ class_definition_keyword("metaclass"). // (expression-resolution/expr-type.dl, clause a'), where the convention is the only // signal there is: a lambda has no annotation and no `def` to classify. instance_receiver_name("self"). + +// ── dict_view_name / dict_view_slot / dict_items_view — a mapping's views ──── +// `keys()` yields the key (generic position 0), `values()` the value (position 1), and +// `items()` a (key, value) pair. Read by resolution/iteration.dl. +dict_view_name("keys"). +dict_view_name("values"). +dict_view_name("items"). +dict_view_slot("keys", "0"). +dict_view_slot("values", "1"). +dict_items_view("items"). diff --git a/graph/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl index 374cc9f69..76d506928 100644 --- a/graph/python/engine/resolution/iteration.dl +++ b/graph/python/engine/resolution/iteration.dl @@ -293,6 +293,79 @@ binding_element_type(p, b, t) :- expr_binding(p, b, _, tgt), element_type_of(p, src, t). +// ── A MAPPING'S VIEWS: `.values()`, `.keys()`, `.items()` ───────────────────── +// self.dump_fields: dict[str, Field] = {} +// for name, field_obj in self.dump_fields.items(): field_obj.serialize(...) +// Every clause above types the container itself; a view is a CALL on it, and none of them +// looked through one, so the commonest way to walk a mapping typed nothing -- and an +// `items()` loop binds a TUPLE target, which no clause bound at all. A mapping annotation +// names its key at generic position 0 and its value at position 1, so the slots are +// read positionally rather than through type_ref_element (which merges both, the safe +// direction for iterating the mapping itself, and wrong for a view that yields one side). +// view_slot names which side each view yields; `items()` yields (key, value) pairs. +// dict_view_slot / dict_items_view are CPython names, catalogued in resolution/builtins.dl. + +// the annotation a mapping-valued expression carries: a field (through self or through +// any typed object), a parameter, or a callee's declared return +mapping_annotation_of(src, r) :- + expr_attr_of_self("client", src, n, owner), + type_attr_field("client", owner, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + attr_access("client", src, n, obj), + expr_type("client", obj, ot), + type_attr_field("client", ot, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + expr_names_param("client", src, ph), + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", k, "METHOD_PARAM", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + call_of_expr(src, site), expr_call_candidate(site, m), + type_ref_owner("client", m, "METHOD", r), + type_ref("client", k, "METHOD_RETURN", _, _, r), annotation_container_kind(k). +// a single-write local holding one of those +mapping_annotation_of(src, r) :- + expr_binding("client", b, ctx, src), ctx != "STORE", + binding_lookup("client", b, b2), + binding_rebinding("client", "1", _, _, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + mapping_annotation_of(val, r). + +// the type at one generic position of an annotation (resolved, or by name in its module) +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", _, _, child), + type_ref_resolved("client", t, child). +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", tn, _, child), tn != "", + type_ref_owner("client", owner, ok, r), + annotation_owner_module("client", owner, ok, mod), + type_name_in_module("client", mod, tn, t). + +// `m.keys()` / `m.values()` / `m.items()` written with no argument +dict_view_call(src, recv, view) :- + call_of_expr(src, site), call_name(site, view), dict_view_name(view), + call_receiver_object(site, recv), !call_arg(site, _, _). +// the element a `keys()` / `values()` view yields +element_type_of("client", src, t) :- + dict_view_call(src, recv, view), dict_view_slot(view, pos), + mapping_annotation_of(recv, r), r != "", type_ref_slot(r, pos, t). +// an `items()` pair's two slots, and the names a TUPLE loop target binds to them +dict_item_slot_type(src, pos, t) :- + dict_items_view(view), dict_view_call(src, recv, view), dict_view_slot(_, pos), + mapping_annotation_of(recv, r), type_ref_slot(r, pos, t). +binding_element_type("client", b, t) :- + iter_pair("client", tgt, src), + expr_node("client", "TUPLE", _, _, tgt), + expr_parent("client", tgt, "ELEMENT", pos, el), + expr_binding("client", b, _, el), + dict_item_slot_type(src, pos, t). + // ── iteration_untyped(Prov, TargetExprHash, IterableExprHash) ──────────────── // A loop whose container the engine could not type. Emitted so the cost is countable // rather than absorbed into a general "receiver untyped". diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 44d353f59..b91eccdb9 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -530,6 +530,8 @@ .decl field_holds_method(c0:symbol,c1:symbol,c2:symbol) .decl type_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_attr_class_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_name(c0:symbol) +.decl dict_items_view(c0:symbol) .decl computed_name_prefix(c0:symbol,c1:symbol) .decl expr_operator(c0:symbol,c1:symbol,c2:symbol) .decl local_holds_untyped_return(c0:symbol,c1:symbol) @@ -670,6 +672,11 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_slot(c0:symbol,c1:symbol) +.decl mapping_annotation_of(c0:symbol,c1:symbol) +.decl type_ref_slot(c0:symbol,c1:symbol,c2:symbol) +.decl dict_view_call(c0:symbol,c1:symbol,c2:symbol) +.decl dict_item_slot_type(c0:symbol,c1:symbol,c2:symbol) .decl method_returns_computed_name(c0:symbol) .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py b/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/schema.py b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py new file mode 100644 index 000000000..6098b261a --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py @@ -0,0 +1,55 @@ +def serialize_field(): + return "s" + + +def deserialize_field(): + return "d" + + +def describe_field(): + return "x" + + +def key_upper(): + return "K" + + +class Field: + def serialize(self, value): + return serialize_field() + + def deserialize(self, value): + return deserialize_field() + + def describe(self): + return describe_field() + + +class Name: + def upper(self): + return key_upper() + + +class Schema: + def __init__(self): + self.dump_fields: dict[str, Field] = {} + self.load_fields: dict[str, Field] = {} + self.names: dict[Name, int] = {} + + def dump(self, obj): + out = {} + for attr_name, field_obj in self.dump_fields.items(): + out[attr_name] = field_obj.serialize(obj) + return out + + def load(self, data): + return [f.deserialize(data) for f in self.load_fields.values()] + + def keys_only(self): + return [k.upper() for k in self.names.keys()] + + def counts(self): + total = 0 + for name, count in self.names.items(): + total += count + return total diff --git a/tests/cases/python/dict-view-loop-types-the-value/case.json b/tests/cases/python/dict-view-loop-types-the-value/case.json new file mode 100644 index 000000000..16c58f1ed --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "`for k, v in self.dump_fields.items()` with `dump_fields: dict[str, Field]`: v is a Field (the VALUE of each item), so v.serialize() resolves", + "run": ["impact", "serialize_field", "--tests"], + "want": ["test_dump.py"], + "avoid": ["test_load.py", "test_keys.py"]}, + {"why": "`for f in self.load_fields.values()`: f is a Field", + "run": ["impact", "deserialize_field", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_dump.py"]}, + {"why": "`.keys()` yields the KEY type: k.upper() on a dict[Name, int] is Name.upper", + "run": ["impact", "key_upper", "--tests"], + "want": ["test_keys.py"]}, + {"why": "CONTROL: an item's first slot is the key, never the value: in `for name, count in names.items()` nothing calls Field", + "run": ["impact", "describe_field", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_keys.py", "test_dump.py"]}]} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py new file mode 100644 index 000000000..5a02e0115 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_dump(): + assert Schema().dump(1) == {} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py new file mode 100644 index 000000000..58c35f100 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py @@ -0,0 +1,9 @@ +from app.schema import Schema + + +def test_keys(): + assert Schema().keys_only() == [] + + +def test_counts(): + assert Schema().counts() == 0 diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py new file mode 100644 index 000000000..a6a326489 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_load(): + assert Schema().load(1) == [] From e4eeb91807e74cc868f03df5207dcddf4a8eaee1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:36:07 -0700 Subject: [PATCH 33/40] fix(python): a parametrize row or a fixture's params= value types the argument the runner fills `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, and `@pytest.fixture(params=(A, B))` hands `request.param` each one in turn; neither is a written call, so the parameter stayed untyped. Parametrize values that are classes, instances or functions occur in fourteen of sixteen public repositories. framework-behavior/dispatch.dl reads the rows: the decorator's values written in place, or a display held by a single-write module name (also through `from m import NAME`); one argument name takes the whole row, several take the row's k-th element (the existing argnames position, now asked for every direct argument); `pytest.param(...)` rows count. value-flow.dl types the parameter from each value (instance, class object, or function), and `request.param` inside a params fixture likewise; a fixture that returns a class makes the requesting parameter that class. The pytest names are catalogued in knobs.dl. Mutation oracle, sixteen repositories: one held-out repository +17 failing test files selected, another +1; 154 added files do not fail (the converter fixture there types every test that takes it; 52 of the 169 added ran the target). Nothing lost. Case parametrize-values-type-the-argument fails 3 of 4 on the base; control: string values naming a class type nothing. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../python/engine/config-resolution/knobs.dl | 8 ++ .../engine/framework-behavior/dispatch.dl | 73 ++++++++++++++++++- graph/python/engine/resolution/value-flow.dl | 21 ++++++ graph/python/souffle/decls_all.dl | 6 ++ .../app/__init__.py | 0 .../app/fields.py | 46 ++++++++++++ .../case.json | 18 +++++ .../tests/__init__.py | 0 .../tests/base.py | 3 + .../tests/conftest.py | 13 ++++ .../tests/test_boxes.py | 8 ++ .../tests/test_convert.py | 2 + .../tests/test_fields.py | 9 +++ .../tests/test_strings.py | 6 ++ 14 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/parametrize-values-type-the-argument/app/__init__.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/app/fields.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/case.json create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/base.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 19231f11c..27c24871f 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -165,6 +165,14 @@ py_parametrize_deco("parametrize"). py_parametrize_argnames_kw("argnames"). py_parametrize_indirect_kw("indirect"). py_parametrize_indirect_all("True"). +// The VALUES the runner hands in: the decorator's second argument (or `argvalues=`), a +// fixture's `params=`, a row wrapped as `pytest.param(v, ..., id=...)`, and, inside a fixture +// declared with params, the attribute `request.param` it reads the current one from. +py_parametrize_argvalues_kw("argvalues"). +py_fixture_params_kw("params"). +py_param_row_wrapper("param"). +py_fixture_request_param("request"). +py_request_param_attr("param"). // ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ── // The runner splits the string on "," and strips each piece. Souffle cannot split, so a diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index c2c8dc38d..f7277835f 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -326,7 +326,7 @@ py_argnames_sep_at(h, s, i) :- py_parametrize_argnames(h, s), py_argnames_sep(se // A whole token: led by the string's start, a separator or a space, and followed by its // end, a separator or a space, so `server` is not found inside `server_url`. .decl py_argnames_found(h:symbol, name:symbol, i:number) -py_argnames_found(h, p, i) :- py_fixture_value_param(h, p), py_parametrize_argnames(h, s), +py_argnames_found(h, p, i) :- py_argnames_demand(h, p), py_parametrize_argnames(h, s), strlen(s) > strlen(p), i = range(0, strlen(s) - strlen(p) + 1), substr(s, i, strlen(p)) = p. .decl py_argnames_led(h:symbol, name:symbol, i:number) py_argnames_led(h, p, 0) :- py_argnames_found(h, p, 0). @@ -346,10 +346,15 @@ py_value_row_kind("LIST"). py_argnames_pos(h, p, k) :- py_argnames_name_at(h, p, i), py_parametrize_argnames(h, s), k = count : { py_argnames_sep_at(h, s, j), j < i }. // ... or, for argnames written as a list or tuple of strings, its element position there. -py_argnames_pos(h, p, k) :- py_fixture_value_param(h, p), +py_argnames_pos(h, p, k) :- py_argnames_demand(h, p), decorator_expr("client", de, h), call_arg(de, "0", a), expr_parent("client", a, "ELEMENT", ks, el), expr_node("client", "LITERAL", _, p, el), k = to_number(ks). +// the argument names whose position is asked for: one a getfixturevalue call reads, and +// every argument the decorator supplies directly (its values type the parameter, below) +.decl py_argnames_demand(h:symbol, p:symbol) +py_argnames_demand(h, p) :- py_fixture_value_param(h, p). +py_argnames_demand(h, p) :- py_parametrize_direct(req, p), py_parametrize_on(req, h). .decl py_fixture_value_param(h:symbol, p:symbol) py_fixture_value_param(h, p) :- py_fixture_value_call(req, e), call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), @@ -362,6 +367,70 @@ py_fixture_request(req, name) :- py_fixture_value_call(req, e), expr_parent("client", tup, "ELEMENT", to_string(k), el), expr_node("client", "LITERAL", _, name, el), name != "". +// ── PARAMETRIZE AND FIXTURE PARAMS: the VALUES the runner hands an argument ── +// @pytest.mark.parametrize("field_cls", [String, Integer]) -> field_cls is one of the classes +// @pytest.mark.parametrize("obj, n", [(Box(), 1), pytest.param(Bag(), 2)]) +// @pytest.fixture(params=(Converter, BaseConverter)) +// def converter_cls(request): return request.param -> one of the classes +// The runner calls the test once per row with the row's values in place of the argument, +// so each value is an argument reaching the parameter exactly as one written at a call +// site is (resolution/value-flow.dl types it). Only values WRITTEN in the decorator, or in +// a display a single-write name in this module (or one it imports the name from) holds; +// a value list built by a call is not read. +.decl py_pz_rows(h:symbol, rows:symbol) +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_arg(de, "1", a). +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_argvalues_kw(k). +py_pz_rows(h, a) :- py_fixture_deco_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_fixture_params_kw(k). +// the display a rows expression is: written in place, or held by a single-write name +.decl py_pz_display(rows:symbol, d:symbol) +py_pz_display(a, a) :- py_pz_rows(_, a), py_value_row_kind(k), expr_node("client", k, _, _, a). +py_pz_display(a, d) :- py_pz_rows(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + py_pz_name_display(b2, d). +.decl py_pz_name_display(b:symbol, d:symbol) +py_pz_name_display(b, d) :- binding_rebinding("client", "1", _, _, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// `from tests.base import ALL_FIELDS`: the module-level binding of that name in the module +py_pz_name_display(b, d) :- import_binding("client", b, i), + import_alias("client", orig, _, i), orig != "", import_relative("client", _, pkg, i), pkg != "", + module_decl("client", _, pkg, _, mod), + binding_decl("client", orig, _, "MODULE_LEVEL", _, mb), binding_owner("client", mod, _, mb), + binding_rebinding("client", "1", _, _, mb), + expr_binding("client", mb, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// a row, with `pytest.param(v, ..., id=...)` read as its positional values +.decl py_pz_row(h:symbol, row:symbol) +py_pz_row(h, r) :- py_pz_rows(h, a), py_pz_display(a, d), expr_parent("client", d, "ELEMENT", _, r). +.decl py_pz_param_call(row:symbol) +py_pz_param_call(r) :- py_pz_row(_, r), call_of_expr(r, site), call_name(site, w), py_param_row_wrapper(w). +// the value at one argument's position in a row: the whole row (one argument name), else +// the row's k-th element +.decl py_pz_single(h:symbol) +py_pz_single(h) :- py_parametrize_argnames(h, s), !py_argnames_sep_at(h, s, _). +py_pz_single(h) :- py_fixture_deco_on(_, h). +.decl py_pz_value(h:symbol, k:number, v:symbol) +py_pz_value(h, 0, r) :- py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r). +py_pz_value(h, 0, v) :- py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), call_arg(r, "0", v). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r), + py_value_row_kind(rk), expr_node("client", rk, _, _, r), + expr_parent("client", r, "ELEMENT", ks, v), k = to_number(ks). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), + call_arg(r, ks, v), k = to_number(ks). +// the parameter each value reaches: a test's directly parametrized argument ... +.decl py_pz_param_value(ph:symbol, v:symbol) +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + !py_pz_single(h), py_argnames_pos(h, name, k), py_pz_value(h, k, v), + param_decl("client", name, _, _, req, ph). +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + py_parametrize_argname(req, h, name), py_pz_single(h), py_pz_value(h, 0, v), + param_decl("client", name, _, _, req, ph). +// ... and, in a fixture declared with params=, what `request.param` evaluates to +.decl py_pz_request_param(e:symbol, v:symbol) +py_pz_request_param(e, v) :- py_fixture_deco_on(f, h), py_pz_value(h, 0, v), + py_fixture_request_param(rq), param_decl("client", rq, _, _, f, ph), + py_request_param_attr(pa), attr_access("client", e, pa, obj), expr_names_param("client", obj, ph). + // (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, // and nothing else; for them it wins over the module's fixture of the name. Read as a // plain same-module fixture it was also handed to a sibling class's tests, and the diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 69fb4ae45..83a3747dc 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -112,6 +112,27 @@ param_arg_type(ph, t) :- param_decl("client", pn, _, _, req, ph), py_fixture_value_type(fix, t). +// ── a parameter the runner fills from a PARAMETRIZE row, or from FIXTURE PARAMS ── +// `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, so +// each value reaches `cls` as an argument would: an instance types it, a class object +// makes it that class, a function makes it callable as that function. Inside a fixture +// declared `params=[...]`, `request.param` is each of those values in turn. +// framework-behavior/dispatch.dl reads the rows (py_pz_param_value, py_pz_request_param). +param_arg_type(ph, t) :- py_pz_param_value(ph, v), expr_type("client", v, t). +param_arg_class(ph, t) :- py_pz_param_value(ph, v), expr_type_class_object("client", v, t). +param_arg_method(ph, m) :- py_pz_param_value(ph, v), expr_denotes_method("client", v, m). +expr_type("client", e, t) :- py_pz_request_param(e, v), expr_type("client", v, t). +expr_type_class_object("client", e, t) :- py_pz_request_param(e, v), expr_type_class_object("client", v, t). +expr_denotes_method("client", e, m) :- py_pz_request_param(e, v), expr_denotes_method("client", v, m). +// a fixture that hands back a CLASS (`return request.param` over classes) makes the +// requesting parameter that class, as a returned instance makes it an instance +py_fixture_value_class(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type_class_object("client", e, t). +param_arg_class(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_class(fix, t). + // ── param_arg_method(ParamHash, MethodHash) — a CALLABLE reaching a parameter ── // `Delegator(target)` puts the FUNCTION `target` into the parameter `fn`. Java would // need a functional interface for this; in Python it is an ordinary assignment. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index b91eccdb9..fdc9b80bb 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -52,6 +52,11 @@ .decl py_fixture_value_method(v:symbol) .decl py_parametrize_deco(v:symbol) .decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_argvalues_kw(v:symbol) +.decl py_fixture_params_kw(v:symbol) +.decl py_param_row_wrapper(v:symbol) +.decl py_fixture_request_param(v:symbol) +.decl py_request_param_attr(v:symbol) .decl py_parametrize_indirect_kw(v:symbol) .decl py_parametrize_indirect_all(v:symbol) .decl py_argnames_sep(v:symbol) @@ -680,3 +685,4 @@ .decl method_returns_computed_name(c0:symbol) .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) +.decl py_fixture_value_class(c0:symbol,c1:symbol) diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/fields.py b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py new file mode 100644 index 000000000..016d46d04 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py @@ -0,0 +1,46 @@ +def string_done(): + return "s" + + +def integer_done(): + return "i" + + +def box_done(): + return "b" + + +def plain_done(): + return "p" + + +def never_done(): + return "n" + + +class String: + def deserialize(self, value): + return string_done() + + +class Integer: + def deserialize(self, value): + return integer_done() + + +class Box: + def open(self): + return box_done() + + +class Converter: + def __init__(self, strict=False): + self.strict = strict + + def structure(self, value): + return plain_done() + + +class Unused: + def structure(self, value): + return never_done() diff --git a/tests/cases/python/parametrize-values-type-the-argument/case.json b/tests/cases/python/parametrize-values-type-the-argument/case.json new file mode 100644 index 000000000..cd7c606eb --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "@parametrize(\"FieldClass\", ALL_FIELDS) where ALL_FIELDS is a list of classes another test module holds: FieldClass is each class, FieldClass() constructs it, and field.deserialize() resolves", + "run": ["impact", "string_done", "--tests"], + "want": ["test_fields.py"], + "avoid": ["test_boxes.py", "test_convert.py"]}, + {"why": "several argument names: `box` is the SECOND element of each row, and pytest.param(...) rows count too", + "run": ["impact", "box_done", "--tests"], + "want": ["test_boxes.py"], + "avoid": ["test_fields.py"]}, + {"why": "a fixture declared params=(Converter,) returns request.param, a CLASS; the fixture that calls it builds a Converter, and the test's converter.structure() resolves", + "run": ["impact", "plain_done", "--tests"], + "want": ["test_convert.py"], + "avoid": ["test_fields.py"]}, + {"why": "CONTROL: string values naming a class are strings, not the class: nothing reaches Unused.structure", + "run": ["impact", "never_done", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_strings.py", "test_convert.py"]}]} diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/base.py b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py new file mode 100644 index 000000000..5827e88d3 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py @@ -0,0 +1,3 @@ +from app.fields import Integer, String + +ALL_FIELDS = [String, Integer] diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py new file mode 100644 index 000000000..92e04e2e4 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from app.fields import Converter + + +@pytest.fixture(params=(Converter,)) +def converter_cls(request): + return request.param + + +@pytest.fixture +def converter(converter_cls): + return converter_cls(strict=True) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py new file mode 100644 index 000000000..2808f6e16 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py @@ -0,0 +1,8 @@ +import pytest + +from app.fields import Box + + +@pytest.mark.parametrize("label, box", [("a", Box()), pytest.param("b", Box(), id="b")]) +def test_open(label, box): + assert box.open() diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py new file mode 100644 index 000000000..906937072 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py @@ -0,0 +1,2 @@ +def test_structure(converter): + assert converter.structure(1) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py new file mode 100644 index 000000000..3e5715cff --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py @@ -0,0 +1,9 @@ +import pytest + +from tests.base import ALL_FIELDS + + +@pytest.mark.parametrize("FieldClass", ALL_FIELDS) +def test_none(FieldClass): + field = FieldClass() + assert field.deserialize(None) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py new file mode 100644 index 000000000..08d096daa --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.mark.parametrize("name", ["Unused", "String"]) +def test_names(name): + assert name.upper() From 8b6c3ece52bb15e9eb6bedc242fd2fdd36cabe6f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:41:47 -0700 Subject: [PATCH 34/40] fix(python): repr(x), len(x), str(x) and the other one-dunder builtins run the argument's dunder `assert repr(v) == "<...>"` reached nothing of v's: the call resolves to the builtin (C code) and the dunder it runs had no caller. BUILTIN_PROTOCOL edges now go from the argument expression to the dunder repr, str, len, hash, bool, iter, next, abs, format and reversed run on the argument's type -- only for the bare builtin (a client `repr` is not it), with exactly one argument (`str(b, "utf-8")` decodes), and with CPython's fallbacks (str -> __repr__, bool -> __len__ when the class has no slot of its own). New call kind BUILTIN_PROTOCOL in the schema vocabulary, tiered by target count like the other protocol edges; the names are catalogued in builtins.dl. Mutation oracle, sixteen repositories: +13 failing test files selected across five repositories and NO added file that does not fail; path found 0.764 -> 0.776 on tuning and 0.600 -> 0.607 held out. Case builtin-runs-the-dunder fails 3 of 4 on the base; control: str() on a class with its own __str__ does not run its __repr__. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../engine/call-edge-generation/call_chain.dl | 34 ++++++++++++++++ graph/python/engine/resolution/builtins.dl | 17 ++++++++ graph/python/souffle/decls_all.dl | 5 +++ .../builtin-runs-the-dunder/app/__init__.py | 0 .../builtin-runs-the-dunder/app/validators.py | 40 +++++++++++++++++++ .../python/builtin-runs-the-dunder/case.json | 17 ++++++++ .../builtin-runs-the-dunder/tests/test_len.py | 6 +++ .../tests/test_other.py | 5 +++ .../tests/test_repr.py | 6 +++ .../builtin-runs-the-dunder/tests/test_str.py | 9 +++++ 12 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/builtin-runs-the-dunder/app/__init__.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/app/validators.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/case.json create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_len.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_other.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_str.py diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index a78cdc699..a39a76d7a 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. - **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls. -- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. +- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. - **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration. ### `call_edges` @@ -535,6 +535,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `CONTEXT_MANAGER` | python | `with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression. | | `ITERATION_PROTOCOL` | python | `for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression. | | `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. | +| `BUILTIN_PROTOCOL` | python | `repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument's class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call. | **`call_edges.tier` values** diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index ac5e5a3c4..c70bf076f 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -671,6 +671,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'CONTEXT_MANAGER', languages: P, meaning: '`with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression.' }, { table: 'call_edges', column: 'kind', value: 'ITERATION_PROTOCOL', languages: P, meaning: '`for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression.' }, { table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' }, + { table: 'call_edges', column: 'kind', value: 'BUILTIN_PROTOCOL', languages: P, meaning: '`repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument\'s class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call.' }, // entry_points.reason { table: 'entry_points', column: 'reason', value: 'main', languages: ['java', 'csharp'], meaning: 'A static `main`. C#: a static `Main`, or the method top-level statements compile to.' }, @@ -749,7 +750,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' }, { language: 'typescript', table: 'type_instantiated', note: 'Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.' }, { language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' }, - { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, + { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, { language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' }, { language: 'python', table: 'call_edges', note: '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.' }, { language: 'java', table: 'call_edges', note: '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.' }, diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index ac802b6ac..c527a151a 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -682,6 +682,33 @@ subscript_protocol_edge(sub, caller, m) :- mro_lookup("client", t, "__class_getitem__", m), expr_ultimate_method("client", sub, caller). +// ── BUILTIN PROTOCOL — `repr(x)` runs type(x).__repr__ ───────────────────────── +// A builtin function that exists to call one dunder of its argument: repr, str, len, +// hash, bool, iter, next, abs, format, reversed. The written call resolves to the builtin +// (C code, boundary_lib), and the dunder it runs -- a client method, often the one a test +// exists to check -- had no caller: `assert repr(v) == "<...>"` reached nothing of v's. +// Only the BARE builtin name (bound to nothing in the client, so it is the builtin), with +// exactly one argument (`str(b, "utf-8")` decodes and runs no __str__), on an argument the +// engine can type. str() falls back to __repr__ and bool() to __len__ when the class +// defines no __str__ / __bool__ of its own, as CPython's type slots do. The builtin names +// and their dunders are catalogued in resolution/builtins.dl (builtin_protocol_slot). +builtin_protocol_arg(e, fn, x) :- + call_of_expr(e, site), call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, _, callee), binding_lookup_unresolved("client", b, fn, _), + builtin_protocol_slot(fn, _), + call_arg(site, "0", x), !call_arg(site, "1", _), !call_kwarg(site, _, _). +// The edge's site is the ARGUMENT expression, as an iteration's is the iterated one: the +// call expression is already the written call to the builtin. +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_slot(fn, d), + expr_type("client", x, t), mro_lookup("client", t, d, m), + expr_ultimate_method("client", x, caller). +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_fallback(fn, d, d2), + expr_type("client", x, t), !type_has_member(t, d), mro_lookup("client", t, d2, m), + expr_ultimate_method("client", x, caller). +type_has_member(t, d) :- builtin_protocol_fallback(_, d, _), mro_lookup("client", t, d, _). + // ── THE THREE PROTOCOL EDGES ARE TIERED BY TARGET COUNT, like every other edge ── // // A property read, a context-manager entry and an iteration all reach their target @@ -725,6 +752,7 @@ protocol_edge(e, d, caller, m) :- property_write_edge(e, caller, m), method_decl protocol_edge(e, d, caller, m) :- with_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- iter_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- subscript_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- builtin_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). // Aggregate in its own stratum, mirroring site_client_target_count above. protocol_edge_target_count(e, d, n) :- @@ -809,6 +837,12 @@ call_chain_edge(sub, caller, "-", m, "client", cls, "SUBSCRIPT_PROTOCOL") :- subscript_protocol_edge(sub, caller, m), method_decl(_, d, _, _, _, m), protocol_edge_class(sub, d, cls). +// BUILTIN PROTOCOL — likewise. Its own kind: the written call is to the builtin, and +// this edge is the dunder that builtin runs, never a second written call. +call_chain_edge(x, caller, "-", m, "client", cls, "BUILTIN_PROTOCOL") :- + builtin_protocol_edge(x, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(x, d, cls). + // A site whose caller could not be determined AT ALL would vanish from every rule // above. That must be impossible (the parser guarantees a non-empty owner), so it is // asserted rather than assumed: any such site is emitted with caller "-" so the diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index 755133bbc..f530f72d8 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -371,6 +371,23 @@ class_definition_keyword("metaclass"). // signal there is: a lambda has no annotation and no `def` to classify. instance_receiver_name("self"). +// ── builtin_protocol_slot(Builtin, Dunder) / builtin_protocol_fallback ─────── +// The one-dunder builtins: `repr(x)` runs type(x).__repr__, and so on. str() falls back to +// __repr__ and bool() to __len__ when the class has no slot of its own. Read by +// call-edge-generation/call_chain.dl's BUILTIN PROTOCOL edges. +builtin_protocol_slot("repr", "__repr__"). +builtin_protocol_slot("str", "__str__"). +builtin_protocol_slot("len", "__len__"). +builtin_protocol_slot("hash", "__hash__"). +builtin_protocol_slot("bool", "__bool__"). +builtin_protocol_slot("iter", "__iter__"). +builtin_protocol_slot("next", "__next__"). +builtin_protocol_slot("abs", "__abs__"). +builtin_protocol_slot("format", "__format__"). +builtin_protocol_slot("reversed", "__reversed__"). +builtin_protocol_fallback("str", "__str__", "__repr__"). +builtin_protocol_fallback("bool", "__bool__", "__len__"). + // ── dict_view_name / dict_view_slot / dict_items_view — a mapping's views ──── // `keys()` yields the key (generic position 0), `values()` the value (position 1), and // `items()` a (key, value) pair. Read by resolution/iteration.dl. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index fdc9b80bb..3ab8b4a1e 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -686,3 +686,8 @@ .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) .decl py_fixture_value_class(c0:symbol,c1:symbol) +.decl builtin_protocol_slot(c0:symbol,c1:symbol) +.decl builtin_protocol_fallback(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_arg(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_has_member(c0:symbol,c1:symbol) diff --git a/tests/cases/python/builtin-runs-the-dunder/app/__init__.py b/tests/cases/python/builtin-runs-the-dunder/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/builtin-runs-the-dunder/app/validators.py b/tests/cases/python/builtin-runs-the-dunder/app/validators.py new file mode 100644 index 000000000..346245b88 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/app/validators.py @@ -0,0 +1,40 @@ +def describe_type(t): + return t.__name__ + + +def count_items(): + return 3 + + +def plain_text(): + return "plain" + + +class InstanceOf: + def __init__(self, type): + self.type = type + + def __repr__(self): + return f"" + + +class Bag: + def __len__(self): + return count_items() + + +class Plain: + def __repr__(self): + return plain_text() + + +class Shown: + def __str__(self): + return "shown" + + def __repr__(self): + return plain_text() + + +def instance_of(type): + return InstanceOf(type) diff --git a/tests/cases/python/builtin-runs-the-dunder/case.json b/tests/cases/python/builtin-runs-the-dunder/case.json new file mode 100644 index 000000000..0f6dc7a40 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "repr(v) runs type(v).__repr__: a test asserting a validator's repr covers what that __repr__ calls", + "run": ["impact", "describe_type", "--tests"], + "want": ["test_repr.py"], + "avoid": ["test_len.py", "test_other.py"]}, + {"why": "len(x) runs __len__, and bool(x) on a class with no __bool__ runs __len__ too", + "run": ["impact", "count_items", "--tests"], + "want": ["test_len.py"], + "avoid": ["test_repr.py"]}, + {"why": "str(x) on a class with no __str__ runs its __repr__", + "run": ["impact", "Plain.__repr__", "--tests"], + "want": ["test_str.py"]}, + {"why": "CONTROL: str(x) on a class that defines __str__ does not run its __repr__, and constructing it runs neither", + "run": ["impact", "Shown.__repr__", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_str.py", "test_other.py"]}]} diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py new file mode 100644 index 000000000..d74f9b857 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py @@ -0,0 +1,6 @@ +from app.validators import Bag + + +def test_len(): + assert len(Bag()) == 3 + assert bool(Bag()) diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py new file mode 100644 index 000000000..115c62814 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py @@ -0,0 +1,5 @@ +from app.validators import Shown + + +def test_other(): + assert Shown() is not None diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py new file mode 100644 index 000000000..705b30de4 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py @@ -0,0 +1,6 @@ +from app.validators import instance_of + + +def test_repr(): + v = instance_of(int) + assert repr(v) == "" diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py new file mode 100644 index 000000000..bd948d90e --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py @@ -0,0 +1,9 @@ +from app.validators import Plain, Shown + + +def test_str_falls_back(): + assert str(Plain()) == "plain" + + +def test_str_own(): + assert str(Shown()) == "shown" From 0646c84eea658281c11e69657cd8557cc3dc6bf4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:53:38 -0700 Subject: [PATCH 35/40] fix(python): a parameter's default value reaches it, as a call-site argument does `def getter(val, field_obj=field_obj): return field_obj.deserialize(val)` -- the closure idiom that pins a loop variable -- is called with one argument, so field_obj only ever holds its default, and no call site passes it: the parameter was untyped and the call on it unresolved. The default is a value the def statement writes once (the parser links it, param_default_expr); value-flow.dl now lets it reach the parameter exactly as a call-site argument does -- an instance types it, a class object makes it that class, a function makes it callable as that function -- alongside whatever callers pass. A parameter defaulted to a name from an enclosing scope appears in fifteen of sixteen public repositories (sentinels and constants included, which type nothing callable). Mutation oracle, sixteen repositories: one tuning repository +13 failing test files selected (16 added files do not fail), none lost, every other repository unchanged. Case parameter-default-types-it fails 2 of 3 on the base; control: an argument passed at a call site still reaches the parameter. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/value-flow.dl | 10 ++++ .../app/__init__.py | 0 .../parameter-default-types-it/app/schema.py | 48 +++++++++++++++++++ .../parameter-default-types-it/case.json | 13 +++++ .../tests/test_emit.py | 5 ++ .../tests/test_load.py | 5 ++ .../tests/test_other.py | 5 ++ 7 files changed, 86 insertions(+) create mode 100644 tests/cases/python/parameter-default-types-it/app/__init__.py create mode 100644 tests/cases/python/parameter-default-types-it/app/schema.py create mode 100644 tests/cases/python/parameter-default-types-it/case.json create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_emit.py create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_load.py create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_other.py diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 83a3747dc..6fd224afe 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -112,6 +112,16 @@ param_arg_type(ph, t) :- param_decl("client", pn, _, _, req, ph), py_fixture_value_type(fix, t). +// ── a parameter's DEFAULT VALUE reaches it ──────────────────────────────────── +// `def getter(val, field_obj=field_obj): return field_obj.deserialize(val)` -- the closure +// idiom that pins a loop variable -- is called with one argument, so the only value +// field_obj ever holds is its default, and no call site passes one. The default is an +// argument the def statement writes once, evaluated where the def runs: one more value +// reaching the parameter, alongside whatever the call sites pass. +param_arg_type(ph, t) :- param_default_expr("client", e, ph), expr_type("client", e, t). +param_arg_class(ph, t) :- param_default_expr("client", e, ph), expr_type_class_object("client", e, t). +param_arg_method(ph, m) :- param_default_expr("client", e, ph), expr_denotes_method("client", e, m). + // ── a parameter the runner fills from a PARAMETRIZE row, or from FIXTURE PARAMS ── // `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, so // each value reaches `cls` as an argument would: an instance types it, a class object diff --git a/tests/cases/python/parameter-default-types-it/app/__init__.py b/tests/cases/python/parameter-default-types-it/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/parameter-default-types-it/app/schema.py b/tests/cases/python/parameter-default-types-it/app/schema.py new file mode 100644 index 000000000..9e9827a54 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/app/schema.py @@ -0,0 +1,48 @@ +def deserialized(): + return "d" + + +def handled(): + return "h" + + +def other_handled(): + return "o" + + +class Field: + def deserialize(self, value): + return deserialized() + + +def default_handler(): + return handled() + + +def other_handler(): + return other_handled() + + +class Schema: + def __init__(self): + self.fields: list[Field] = [Field()] + + def getters(self): + out = [] + for field_obj in self.fields: + def getter(val, field_obj=field_obj): + return field_obj.deserialize(val) + out.append(getter) + return out + + +def load(data): + return [g(data) for g in Schema().getters()] + + +def notify(event, handler=default_handler): + return handler() + + +def emit(): + return notify("e") diff --git a/tests/cases/python/parameter-default-types-it/case.json b/tests/cases/python/parameter-default-types-it/case.json new file mode 100644 index 000000000..76acc212a --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a closure's `field_obj=field_obj` default pins a loop variable: field_obj is a Field, so field_obj.deserialize() resolves and a test of load() covers it", + "run": ["impact", "deserialized", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_emit.py"]}, + {"why": "a callable default (`handler=default_handler`) is what handler() runs when the caller passes none", + "run": ["impact", "handled", "--tests"], + "want": ["test_emit.py"]}, + {"why": "CONTROL: an argument passed at a call site still reaches the parameter alongside the default", + "run": ["impact", "other_handled", "--tests"], + "want": ["test_other.py"], + "avoid": ["test_load.py"]}]} diff --git a/tests/cases/python/parameter-default-types-it/tests/test_emit.py b/tests/cases/python/parameter-default-types-it/tests/test_emit.py new file mode 100644 index 000000000..4eb608e8f --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_emit.py @@ -0,0 +1,5 @@ +from app.schema import emit + + +def test_emit(): + assert emit() == "h" diff --git a/tests/cases/python/parameter-default-types-it/tests/test_load.py b/tests/cases/python/parameter-default-types-it/tests/test_load.py new file mode 100644 index 000000000..b4b85e5d1 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import load + + +def test_load(): + assert load(1) == ["d"] diff --git a/tests/cases/python/parameter-default-types-it/tests/test_other.py b/tests/cases/python/parameter-default-types-it/tests/test_other.py new file mode 100644 index 000000000..2084637a2 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_other.py @@ -0,0 +1,5 @@ +from app.schema import notify, other_handler + + +def test_other(): + assert notify("e", other_handler) == "o" From 857e8f144e9579a27f5408d027e905db3d887489 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:12:30 -0700 Subject: [PATCH 36/40] fix(parser/python): an import more qualified than a module resolves to it only where the module sits under the dropped segments findModule dropped leading segments of an import it could not match exactly and then matched what was left exactly or by suffix. `from clikit.testing import CliRunner` dropped `clikit` and found the project's own `testing` package (or `app.testing` by suffix); `from fw.db import models` found the project's `models.py`. A dependency's module whose last segments collide with one of the project's resolved INTO the project, the import bound nothing, and every call through it was unresolved instead of a named boundary. The drop exists for an import more qualified than the module (`import myproject.framework` naming a `framework` in a `myproject/` directory with no __init__.py). The analyzer now records the directories above each module's top package, and a dropped prefix must be what the module actually sits under. Python engine goldens: 33-url-view-forms, 37-model-signals and 38-app-wiring move sites from ambiguous_unknown to boundary_lib (`external:path`, `external:models.CharField`, `external:call_command`); edges, framework edges and the torture harness are otherwise unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../python/expected/33-url-view-forms.edges | 6 ++-- .../python/expected/33-url-view-forms.tiers | 10 +++--- .../python/expected/37-model-signals.edges | 4 +-- .../python/expected/37-model-signals.tiers | 9 +++-- .../test/python/expected/38-app-wiring.edges | 4 +-- .../test/python/expected/38-app-wiring.tiers | 9 +++-- .../extractors/python-resolution-linker.ts | 35 +++++++++++++++++-- .../python/python-project-analyzer.ts | 10 ++++++ 8 files changed, 64 insertions(+), 23 deletions(-) diff --git a/graph/test/python/expected/33-url-view-forms.edges b/graph/test/python/expected/33-url-view-forms.edges index f5bb9f3f3..c05006003 100644 --- a/graph/test/python/expected/33-url-view-forms.edges +++ b/graph/test/python/expected/33-url-view-forms.edges @@ -1,9 +1,11 @@ ambiguous_unknown DECORATOR_APPLICATION shop.decorators.audited -> - ambiguous_unknown DECORATOR_BARE shop.views. -> - ambiguous_unknown METHOD_CALL shop.urls. -> - -ambiguous_unknown SIMPLE_CALL catalog.urls. -> - -ambiguous_unknown SIMPLE_CALL shop.urls. -> - boundary_lib DECORATOR_CALL shop.decorators.audited -> external:functools.wraps +boundary_lib SIMPLE_CALL catalog.urls. -> external:path +boundary_lib SIMPLE_CALL shop.urls. -> external:include +boundary_lib SIMPLE_CALL shop.urls. -> external:path +boundary_lib SIMPLE_CALL shop.urls. -> external:re_path known_edge DECORATOR_BARE shop.views. -> shop.decorators.audited known_edge SIMPLE_CALL shop.decorators.audited..wrapper -> shop.views.order_history known_edge SIMPLE_CALL shop.views.BaseWidgetView.get -> shop.views._render diff --git a/graph/test/python/expected/33-url-view-forms.tiers b/graph/test/python/expected/33-url-view-forms.tiers index 2713fd477..f05de8d3f 100644 --- a/graph/test/python/expected/33-url-view-forms.tiers +++ b/graph/test/python/expected/33-url-view-forms.tiers @@ -1,8 +1,8 @@ distinct call sites emitted: 51 --- by tier: edge ROWS, and the distinct SITES they cover --- - 32 rows 32 sites ambiguous_unknown - 1 rows 1 sites boundary_lib + 6 rows 6 sites ambiguous_unknown + 27 rows 27 sites boundary_lib 18 rows 18 sites known_edge --- edge rows by call kind --- @@ -14,13 +14,13 @@ distinct call sites emitted: 51 --- unresolved reasons --- 1 decorator_factory_result_untyped - 27 no_rule + 1 no_rule 4 unmodelled_decorator --- the engine's own conservation ledger --- 51 _total_sites - 32 ambiguous_unknown - 1 boundary_lib + 6 ambiguous_unknown + 27 boundary_lib 18 known_edge --- reconciling rows against the conserved site count --- diff --git a/graph/test/python/expected/37-model-signals.edges b/graph/test/python/expected/37-model-signals.edges index a50f46f31..675318292 100644 --- a/graph/test/python/expected/37-model-signals.edges +++ b/graph/test/python/expected/37-model-signals.edges @@ -1,6 +1,4 @@ ambiguous_unknown DECORATOR_APPLICATION models. -> - -ambiguous_unknown METHOD_CALL models.Invoice. -> - -ambiguous_unknown METHOD_CALL models.Order. -> - ambiguous_unknown METHOD_CALL views.create_order -> - ambiguous_unknown METHOD_CALL views.drop_invoice -> - ambiguous_unknown METHOD_CALL views.drop_order -> - @@ -9,4 +7,6 @@ ambiguous_unknown METHOD_CALL views.save_draft -> - ambiguous_unknown METHOD_CALL views.touch_invoice -> - boundary_lib DECORATOR_CALL models. -> external:receiver boundary_lib METHOD_CALL models. -> external:post_save.connect +boundary_lib METHOD_CALL models.Invoice. -> external:models.IntegerField +boundary_lib METHOD_CALL models.Order. -> external:models.CharField known_edge METHOD_CALL views.save_cache -> views.Cache.save diff --git a/graph/test/python/expected/37-model-signals.tiers b/graph/test/python/expected/37-model-signals.tiers index a7e08a5cf..e87fd3fad 100644 --- a/graph/test/python/expected/37-model-signals.tiers +++ b/graph/test/python/expected/37-model-signals.tiers @@ -1,8 +1,8 @@ distinct call sites emitted: 14 --- by tier: edge ROWS, and the distinct SITES they cover --- - 10 rows 10 sites ambiguous_unknown - 3 rows 3 sites boundary_lib + 8 rows 8 sites ambiguous_unknown + 5 rows 5 sites boundary_lib 1 rows 1 sites known_edge --- edge rows by call kind --- @@ -14,13 +14,12 @@ distinct call sites emitted: 14 2 decorator_factory_result_untyped 4 escape_hatch 1 untyped_receiver:class_attribute_absent - 2 untyped_receiver:local_untyped 1 untyped_receiver:parameter --- the engine's own conservation ledger --- 14 _total_sites - 10 ambiguous_unknown - 3 boundary_lib + 8 ambiguous_unknown + 5 boundary_lib 1 known_edge --- reconciling rows against the conserved site count --- diff --git a/graph/test/python/expected/38-app-wiring.edges b/graph/test/python/expected/38-app-wiring.edges index 89dad32f2..39d93d28d 100644 --- a/graph/test/python/expected/38-app-wiring.edges +++ b/graph/test/python/expected/38-app-wiring.edges @@ -3,8 +3,6 @@ ambiguous_unknown DECORATOR_ATTRIBUTE main. -> - ambiguous_unknown DECORATOR_BARE main. -> - ambiguous_unknown SIMPLE_CALL main.Timing.dispatch -> - ambiguous_unknown SIMPLE_CALL main.add_header -> - -ambiguous_unknown SIMPLE_CALL shop.jobs.nightly -> - -ambiguous_unknown SIMPLE_CALL shop.jobs.unknown -> - boundary_lib DECORATOR_CALL main. -> external:APIRouter.api_route boundary_lib DECORATOR_CALL main. -> external:FastAPI.exception_handler boundary_lib DECORATOR_CALL main. -> external:FastAPI.middleware @@ -17,6 +15,8 @@ boundary_lib SIMPLE_CALL main. -> external:FastAPI boundary_lib SIMPLE_CALL main. -> external:Route boundary_lib SIMPLE_CALL main. -> external:Starlette boundary_lib SIMPLE_CALL main. -> external:WebSocketRoute +boundary_lib SIMPLE_CALL shop.jobs.nightly -> external:call_command +boundary_lib SIMPLE_CALL shop.jobs.unknown -> external:call_command known_edge METHOD_CALL main. -> main.Registry.add_route known_edge SIMPLE_CALL main. -> main.configure known_edge SIMPLE_CALL shop.management.commands.close_orders.Command.handle -> shop.orders.close_all diff --git a/graph/test/python/expected/38-app-wiring.tiers b/graph/test/python/expected/38-app-wiring.tiers index 4df06d8aa..8496a4b97 100644 --- a/graph/test/python/expected/38-app-wiring.tiers +++ b/graph/test/python/expected/38-app-wiring.tiers @@ -1,8 +1,8 @@ distinct call sites emitted: 26 --- by tier: edge ROWS, and the distinct SITES they cover --- - 10 rows 10 sites ambiguous_unknown - 13 rows 13 sites boundary_lib + 8 rows 8 sites ambiguous_unknown + 15 rows 15 sites boundary_lib 3 rows 3 sites known_edge --- edge rows by call kind --- @@ -16,13 +16,12 @@ distinct call sites emitted: 26 --- unresolved reasons --- 2 callee_is_parameter 4 decorator_factory_result_untyped - 2 no_rule 2 unmodelled_decorator --- the engine's own conservation ledger --- 26 _total_sites - 10 ambiguous_unknown - 13 boundary_lib + 8 ambiguous_unknown + 15 boundary_lib 3 known_edge --- reconciling rows against the conserved site count --- diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c54857..f226446d8 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,13 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * The directory names above this module's top-level package, outermost first: `["repo", "src"]` for + * `repo/src/pkg/mod.py`. An import more qualified than the module (`import myproject.framework` naming a + * `framework` that sits in a `myproject/` directory with no `__init__.py`) is matched by dropping its leading + * segments, and those segments must then be the directories the module actually sits in. + */ + outerDirs?: string[]; } export interface ProjectResolutionStats { @@ -1839,18 +1846,42 @@ export class PythonResolutionLinker { const parts = name.split('.'); for (let drop = 1; drop < parts.length; drop++) { const candidate = parts.slice(drop).join('.'); + const dropped = parts.slice(0, drop); + // THE DROPPED SEGMENTS MUST BE WHERE THE MODULE SITS. Dropping them is for an import more qualified than the + // module; dropping any leading segment turned `from clikit.testing import CliRunner` into the project's own + // `testing` (or `app.testing`, by suffix) — a dependency whose module shares a last segment with one of the + // project's resolved INTO the project, and its import bound nothing. const direct = moduleByQualifiedName.get(candidate); - if (direct) { + if (direct && this.sitsUnder(direct, candidate, dropped)) { return direct; } const suffixed = this.moduleSuffixIndex.get(candidate); - if (suffixed) { + if (suffixed && this.sitsUnder(suffixed, candidate, dropped)) { return suffixed; } } return undefined; } + /** + * Whether `dropped` names what lies directly above `tail` on the module's own path: the part of its qualified + * name before the matched tail, then the directories above its top-level package. A module with no recorded + * directories keeps the old answer. + */ + private sitsUnder(module: ProjectModuleFacts, tail: string, dropped: string[]): boolean { + if (module.outerDirs === undefined) { + return true; + } + const own = module.qualifiedName.split('.'); + const before = own.slice(0, Math.max(0, own.length - tail.split('.').length)); + const chain = [...module.outerDirs, ...before]; + if (chain.length < dropped.length) { + return false; + } + const end = chain.slice(chain.length - dropped.length); + return end.every((segment, i) => segment === dropped[i]); + } + /** * Registers every module under every dotted suffix of its qualified name. * diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a5..739b8f212 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + outerDirs: this.outerDirsOf(filePath, facts.module.getQualifiedName()), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, @@ -577,6 +578,15 @@ export class PythonProjectAnalyzer { return path.relative(rootDir, filePath) || path.basename(filePath); } + /** The directory names above a module's top-level package, outermost first (see ProjectModuleFacts.outerDirs). */ + private outerDirsOf(filePath: string, qualifiedName: string): string[] { + const dirs = path.dirname(path.resolve(filePath)).split(path.sep).filter(part => part !== ''); + const inPackage = isPythonPackageInitFileName(path.basename(filePath)) + ? qualifiedName.split('.').length + : qualifiedName.split('.').length - 1; + return dirs.slice(0, Math.max(0, dirs.length - inPackage)); + } + private moduleQualifiedNameFor(rootDir: string, filePath: string): string { const parsed = path.parse(filePath); const segments: string[] = []; From 32f0b3525016c50589a7aecd2f1707bcc0911bb4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:12:51 -0700 Subject: [PATCH 37/40] feat(python): library callback summaries -- what an installed library hands back to the objects the client gives it The engine reads a dependency's signatures, never its bodies, so a hop the LIBRARY makes back into client code was invisible: a test client's get() runs the open() a client subclass overrides and then calls the application it was built around; a CLI runner's invoke(cli) runs cli.main(), which runs the command's callback; a decorator wraps a def into a library object that later calls it; ExitStack.enter_context(cm) runs cm.__enter__. On a 16-repository Python oracle a library frame was the first broken hop behind most test-impact misses of the web-framework subject, and every one of them stopped at the library. graph/python/libsum/libsum.py derives the hand-backs from the library's SOURCE (the project's own environment, its stdlib through pyvenv.cfg, or AXIOMCODE_PY_SITE): a flow-insensitive pass per function records which parameter-rooted value meets which call or protocol syntax, composed to a fixpoint across the library's own calls (self-calls, typed parameters and fields, constructors, closures, decorator factories). It writes, for the library classes and functions the client can name, five tables beside the client IR: the hand-backs (callee, root, position, path, member, via, declared type), constructor stores, re-exports, what a decorator turns a def into, and class ancestors. Cached by the installed distributions and the client's imports, so a refresh that adds no import reuses them. Staged through a new client-extra.map (facts beside the IR that are not parser schema); absent = empty = no change. framework-behavior/library-callbacks.dl joins them to the client: a member a client type inherits from its library base, a super() past the client classes, a library function or class the client imported, a member of a library object the client built (followed through locals, helpers' returns and fixtures) or one a library decorator made of a def. Each hand-back resolves to the client member of the object that arrives -- the receiver, an argument, a field the library constructor filled, a field the client set -- and only when none answers, to the client subclasses of the type the library declares there. It ADDS lib_callback_edge (caller, target, callee, member, via, how) and lib_callback_site, and leaves call_chain_edge alone. No library or framework is named in a rule. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 7 + graph/pipeline/run-souffle.sh | 8 +- .../framework-behavior/library-callbacks.dl | 368 ++++++ graph/python/libsum/libsum.py | 1090 +++++++++++++++++ graph/python/souffle/decls_all.dl | 8 + graph/python/souffle/export_manifest.tsv | 2 + graph/python/templates/client-extra.map | 12 + graph/test/python/tools/literal_gate.py | 7 +- 8 files changed, 1498 insertions(+), 4 deletions(-) create mode 100644 graph/python/engine/framework-behavior/library-callbacks.dl create mode 100644 graph/python/libsum/libsum.py create mode 100644 graph/python/templates/client-extra.map diff --git a/bin/axiomcode b/bin/axiomcode index c3948dfad..99f3f6688 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -295,6 +295,13 @@ case "$cmd" in echo "▶ parsing $src → IR" "$0" parser "$src" "$ir" ${popts[@]+"${popts[@]}"} > "$int/parse.log" 2>&1 || { echo "❌ parser failed — see $int/parse.log" >&2; tail -5 "$int/parse.log" >&2; exit 1; } head -1 "$int/parse.log" + # WHAT AN INSTALLED PYTHON LIBRARY HANDS BACK to the objects the client gives it (graph/python/libsum): read from + # the project's environment, cached by package and version, staged beside the client IR. Never fatal -- without + # it the engine sees no library hand-backs, exactly as before. AXIOMCODE_LIB_CALLBACKS=0 turns it off. + if [ -d "$ir/python" ] && [ "${AXIOMCODE_LIB_CALLBACKS:-1}" != 0 ]; then + python3 "$ROOT/graph/python/libsum/libsum.py" --src "$src" --out "$ir/python" > "$int/libsum.log" 2>&1 \ + && tail -1 "$int/libsum.log" || echo "⚠️ library callback summaries failed (see $int/libsum.log); continuing without them" >&2 + fi # which languages the IR holds — one folder each — and which of them have an engine found=(); for d in "$ir"/*/; do l="$(basename "$d")"; [ -d "$ROOT/graph/$l/engine" ] && found+=("$l"); done [ ${#found[@]} -gt 0 ] || { echo "❌ no language with an engine in the IR (folders: $(ls "$ir" | tr '\n' ' '))" >&2; exit 1; } diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index b0e9fd5b0..6b9075707 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -201,6 +201,7 @@ write_program(){ _SU=() t+="#include \"$LANG_ARG/souffle/decls_base.dl\"$nl#include \"$LANG_ARG/souffle/decls_all.dl\"$nl" map_rels_su "$TPL/client-ir.map" || return 1 + if [ -f "$TPL/client-extra.map" ]; then map_rels_su "$TPL/client-extra.map" || return 1; fi map_rels_su "$TPL/lib.map" sig || return 1 for r in $LIB_BODY; do _SU+=("$r"); done _SU+=(jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world) @@ -254,7 +255,8 @@ write_program(){ # lost on either side shows up as a difference. Every .input and .output line must be exactly # the expected one, each exactly once, with nothing extra. Exit status only: no pipe to lose. verify_program(){ - awk -v cmap="$TPL/client-ir.map" -v lmap="$TPL/lib.map" -v man="$DL/export_manifest.tsv" \ + local xmap=""; [ -f "$TPL/client-extra.map" ] && xmap="$TPL/client-extra.map" + awk -v cmap="$TPL/client-ir.map" -v xmap="$xmap" -v lmap="$TPL/lib.map" -v man="$DL/export_manifest.tsv" \ -v libsig=" $LIB_SIG " -v extra="$LIB_BODY jdk_max_depth lib_max_depth taint_gating dispatch_cap dispatch_closed_world" ' function want(line) { if (!(line in need)) { need[line] = 1; n++ } } function inp(r) { want(".input " r "(IO=file, filename=\"" r ".facts\", delimiter=\"\\t\", rfc4180=true)") } @@ -269,7 +271,7 @@ verify_program(){ close(path) } BEGIN { - maprels(cmap, 0); maprels(lmap, 1) + maprels(cmap, 0); maprels(lmap, 1); if (xmap != "") maprels(xmap, 0) m = split(extra, e, " "); for (i = 1; i <= m; i++) if (e[i] != "") inp(e[i]) while ((rc = (getline l < man)) > 0) { sub(/^\t+/, "", l); k = index(l, "\t"); p = l; f = "" @@ -813,7 +815,7 @@ while IFS=$'\t' read -r rel csv; do if [ -f "$CLIENT/$csv.csv" ]; then awk -F'\t' 'NR==1{n=NF; next} NF==n{print; next} {bad++} END{if(bad>0) printf " ! dropped %d malformed row(s) from %s\n", bad, FILENAME > "/dev/stderr"}' "$CLIENT/$csv.csv" > "$FACTS/$rel.facts" else : > "$FACTS/$rel.facts"; fi CLIENT_INPUTS="$CLIENT_INPUTS$rel"$'\n' -done < <(read_map "$TPL/client-ir.map") +done < <(read_map "$TPL/client-ir.map"; if [ -f "$TPL/client-extra.map" ]; then read_map "$TPL/client-extra.map"; fi) # --- LIB: signature relations, concatenated across every module of every root --- # CACHED. This concatenation reads the ENTIRE library IR (the JDK alone is 2.0 GB in, diff --git a/graph/python/engine/framework-behavior/library-callbacks.dl b/graph/python/engine/framework-behavior/library-callbacks.dl new file mode 100644 index 000000000..e17d9043c --- /dev/null +++ b/graph/python/engine/framework-behavior/library-callbacks.dl @@ -0,0 +1,368 @@ +// ============================================================================ +// FRAMEWORK-BEHAVIOR · LIBRARY CALLBACKS (the hop a dependency makes back into the client) +// +// ── THE GAP ───────────────────────────────────────────────────────────────── +// The engine reads a dependency's signatures, never its bodies. So when a library calls +// BACK into client code, the call graph stops at the library frame: a test client's +// `client.get("/")` runs the `open()` a client subclass overrides and then the +// application object it was built around (`app(environ, start_response)`); a CLI test +// runner's `runner.invoke(cli)` runs `cli.main()`, which runs the command's callback; an +// `ExitStack.enter_context(cm)` runs `cm.__enter__`. Measured on a 16-repository Python +// oracle, a library frame is the first broken hop behind most test-impact misses on the +// web-framework subject: the test calls the library, the library calls the code under +// change, and the graph has only the first half. +// +// ── WHERE THE KNOWLEDGE COMES FROM ────────────────────────────────────────── +// graph/python/libsum/libsum.py reads the installed library's SOURCE once (cached by the +// installed distributions and the client's imports, never solved here) and stages these tables +// beside the client IR, for the library classes and functions the client can name: +// py_lib_callback(Callee, Root, Pos, Path, Member, Via, Type) +// calling the library callable Callee hands back `Member` on the object that arrived +// as its parameter Root (written by the client at position Pos, -1 = the receiver +// `self`), after following the attribute Path ("" = the object itself); Via is the +// library function where that invocation is written; Type the class the library declares that +// object to be, "" when it declares none +// py_lib_ancestor(Class, Ancestor) a library class's library ancestors +// py_lib_field_init(Class, Field, Param, Pos) +// constructing Class stores the argument Param on the new object as Field +// py_lib_alias(Alias, Canonical) +// a name a package re-exports +// py_lib_decorates(Decorator, Form, Class, Field) +// `@Decorator` (DIRECT) or `@Decorator(...)` (FACTORY) turns a def into a Class whose Field +// holds it; Class "" when the def itself comes back +// No row for a library written in C: a declared blind spot, never a guess. +// +// ── WHAT IT EMITS, AND WHY NOT AS A CALL ──────────────────────────────────── +// lib_callback_edge(Caller, Target, Callee, Member, Via, How) +// Caller the client method containing the call into the library +// Target the client method the library is summarised to call back +// Callee the library callable the client called (canonical name) +// How receiver Target is a member of the object the call was made on +// (a client subclass overriding what the library's own code calls) +// receiver_field ... of an object the library holds in a field of it +// argument ... of an object (or the callable itself) passed as an argument +// argument_field ... of an object held in a field of an argument +// The contract of this phase holds: it only ADDS a relation. The hop is a library's +// behaviour summarised from its source, joined to the client by a member NAME on a type +// the client handed over; a resolved call is stronger knowledge, so call_chain_edge is +// untouched and a consumer opts in (impact walks it as its own hop, `library callback`). +// No rule here names a library or a framework: every name comes from the staged tables. +// ============================================================================ + +// ── the dotted path a client expression spells through an unlinked import ─── +// `Client` after `from webkit.testing import Client`, `clikit.command` after `import clikit`: the import's path, +// then each attribute read off it. Only an import that linked to nothing in the client starts one. +.decl lcb_path_of(e:symbol, p:symbol) +lcb_path_of(e, p) :- + expr_binding("client", b, ctx, e), ctx != "STORE", + binding_lookup("client", b, b2), import_unlinked(b2, p). +lcb_path_of(e, cat(p, cat(".", n))) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), n != "", + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), lcb_path_of(obj, p), strlen(p) < 200. + +// ── its canonical name, following the package's re-exports ────────────────── +.decl lcb_named(p:symbol) +lcb_named(p) :- lcb_path_of(_, p). +.decl lcb_canon(p:symbol, c:symbol) +lcb_canon(p, c) :- lcb_named(p), py_lib_alias(p, c). +lcb_canon(p, p) :- lcb_named(p), !py_lib_alias(p, _). +.decl lcb_expr_names(e:symbol, c:symbol) +lcb_expr_names(e, c) :- lcb_path_of(e, p), lcb_canon(p, c). + +// ── a client class whose base is a library class ───────────────────────────── +// Written `class AppClient(Client)` with `Client` imported from a dependency that was not +// staged: the base resolves to nothing and type_base_unresolved records it by text. Here it +// is named by the import the text binds to, and inherited through client subclasses. +.decl lcb_direct_ext_base(t:symbol, c:symbol) +lcb_direct_ext_base(t, c) :- + type_base_slot("client", t, _, "NAME", _, _, bh), base_expr("client", bh, e), lcb_expr_names(e, c). +lcb_direct_ext_base(t, c) :- + type_base_slot("client", t, _, "SUBSCRIPT", _, _, bh), base_expr("client", bh, e), + expr_parent("client", e, "SUBSCRIPT_OBJECT", _, obj), lcb_expr_names(obj, c). +.decl lcb_ext_class(t:symbol, c:symbol) +lcb_ext_class(t, c) :- lcb_direct_ext_base(t, c). +lcb_ext_class(t, c) :- type_ancestor("client", t, a), lcb_direct_ext_base(a, c). + +// a client type and every client subclass of it: the object a library calls back may be a +// subclass of the type the call site was written against +.decl lcb_self_type(t:symbol, s:symbol) +lcb_self_type(t, t) :- lcb_ext_class(t, _). +lcb_self_type(t, s) :- lcb_ext_class(t, _), type_ancestor("client", s, t). + +.decl lcb_has_rows(c:symbol) +lcb_has_rows(c) :- py_lib_callback(c, _, _, _, _, _, _). + +// ── lcb_entry(Site, Callee, SelfType) — a client call into a summarised library callable ── +// SelfType is the client type the library's `self` is, or "" when the receiver is not a +// client object. +.decl lcb_entry(site:symbol, callee:symbol, selfT:symbol) +// (a) a member the client type inherits from its library base and does not define itself: +// `client.get("/")` on an AppClient-like subclass, `self.get(...)` inside one +lcb_entry(site, q, t) :- + call_receiver_object(site, obj), expr_type("client", obj, t), + call_name(site, n), n != "", + !expr_call_candidate(site, _), + lcb_ext_class(t, c), q = cat(c, cat(".", n)), lcb_has_rows(q). +// (b) super().n(...) whose MRO leaves the client for a library base +lcb_entry(site, q, a) :- + call_recv_kind(site, "SUPER"), call_super_anchor(site, _, a), + call_name(site, n), n != "", + !expr_call_candidate(site, _), + lcb_ext_class(a, c), q = cat(c, cat(".", n)), lcb_has_rows(q). +// (c) a library function or class called by a name the client imported +lcb_entry(site, c, "") :- + call_callee_is_value(site), call_callee_expr(site, callee), + lcb_expr_names(callee, c), lcb_has_rows(c). +// (d) a member of a library object the client built: `runner = CliRunner(); runner.invoke(cli)`, or one a +// fixture or a helper returned. Its `self` is a library object, so it has no client type; what the client +// gave its constructor is reached through the allocation (lcb_lobj_entry below). +lcb_entry(site, q, "") :- lcb_lobj_entry(site, q, _). +// (e) constructing a client class whose constructor is the library base's: its __init__ may +// call members the client overrides +lcb_entry(site, c, t) :- + call_constructs_type(site, t), lcb_ext_class(t, c), + !type_constructor("client", t, _), lcb_has_rows(c). + +// (f) a hand-back to a member the client object does not define but INHERITS from a library class: the +// library calls `cli.main()` on the command it was given, `main` is the library base's own, and that +// method's hand-backs apply to the same object (its make_context override). Same site, same caller. +lcb_entry(site, q2, t) :- + lcb_arg(site, q, r, a), py_lib_callback(q, r, _, "", n, _, _), + expr_type("client", a, t), !mro_lookup("client", t, n, _), + lcb_ext_class(t, c), q2 = cat(c, cat(".", n)), lcb_has_rows(q2). +lcb_entry(site, q2, t) :- + lcb_entry(site, q, t), t != "", py_lib_callback(q, "self", _, "", n, _, _), + !mro_lookup("client", t, n, _), + lcb_ext_class(t, c), q2 = cat(c, cat(".", n)), lcb_has_rows(q2). + +// ── lcb_ext_instance(Expr, Class, Alloc) — an expression holding an object of a library class ── +// Alloc is the call site that constructed it. Followed through a single-binding local, a client function's +// return value, and the fixture a test parameter is filled from. +.decl lcb_ext_instance(e:symbol, c:symbol, alloc:symbol) +lcb_ext_instance(e, c, site) :- + call_of_expr(e, site), call_callee_is_value(site), call_callee_expr(site, callee), + lcb_expr_names(callee, c), lcb_is_class(c). +lcb_ext_instance(e, c, al) :- + expr_binding("client", bref, ctx, e), ctx != "STORE", + binding_lookup("client", bref, bind), + binding_rebinding("client", "1", _, _, bind), + expr_binding("client", bind, "STORE", tgt), + assign_pair("client", tgt, val), + lcb_ext_instance(val, c, al). +lcb_ext_instance(e, c, al) :- + call_of_expr(e, site), expr_call_candidate(site, m), + method_return_value_expr("client", m, r), lcb_ext_instance(r, c, al). +lcb_ext_instance(e, c, al) :- + expr_names_param("client", e, ph), param_decl("client", pn, _, _, req, ph), + py_fixs_injection(req, fix, pn), method_return_value_expr("client", fix, r), lcb_ext_instance(r, c, al). +lcb_ext_instance(e, c, al) :- + expr_names_param("client", e, ph), param_decl("client", pn, _, _, req, ph), + py_fixs_injection(req, fix, pn), + expr_node("client", "YIELD", _, _, y), expr_ultimate_method("client", y, fix), + expr_parent("client", y, "YIELD_VALUE", _, v), lcb_ext_instance(v, c, al). +.decl lcb_is_class(c:symbol) +lcb_is_class(c) :- py_lib_field_init(c, _, _, _). +lcb_is_class(c) :- py_lib_callback(c, "self", _, _, _, _, _). + +// ── LIBRARY OBJECTS THE CLIENT HOLDS ──────────────────────────────────────── +// lcb_lobj(Expr, Class, Obj): Expr holds a library object of Class, identified by Obj -- the call site that +// constructed it, or the client function a library decorator wrapped into it (`@command() def greet()` makes +// `greet` a Command whose `callback` is the def). Its fields are what the client gave it. +.decl lcb_lobj(e:symbol, c:symbol, obj:symbol) +lcb_lobj(e, c, al) :- lcb_ext_instance(e, c, al). +lcb_lobj(e, c, m) :- lcb_names_def(e, m), lcb_deco_obj(m, c, _). + +// the def a name denotes, decorated or not (the decorated name's VALUE is what the decorator returned) +.decl lcb_names_def(e:symbol, m:symbol) +lcb_names_def(e, m) :- expr_binding("client", bref, ctx, e), ctx != "STORE", binding_lookup("client", bref, b), + method_declaring_binding("client", b, m). +lcb_names_def(e, m) :- expr_binding("client", bref, ctx, e), ctx != "STORE", binding_lookup("client", bref, b), + import_binds_method("client", b, m). +lcb_names_def(e, m) :- expr_attr_names_method("client", e, m). + +// the library callable a decorator expression applies: `@q` (direct) or `@q(...)` (factory) +.decl lcb_deco_callable(d:symbol, q:symbol, form:symbol) +lcb_deco_callable(d, q, "DIRECT") :- decorator_expr("client", e, d), !call_of_expr(e, _), lcb_expr_names(e, q). +lcb_deco_callable(d, q, "FACTORY") :- decorator_expr("client", e, d), call_of_expr(e, site), + call_callee_expr(site, callee), lcb_expr_names(callee, q). +lcb_deco_callable(d, q, "FACTORY") :- decorator_expr("client", e, d), call_of_expr(e, site), + call_receiver_object(site, obj), expr_type("client", obj, t), call_name(site, n), !mro_lookup("client", t, n, _), + lcb_ext_class(t, c), q = cat(c, cat(".", n)). +// a decorator that hands the function straight back (registers it, records an option on it) +.decl lcb_deco_identity(d:symbol) +lcb_deco_identity(d) :- lcb_deco_callable(d, q, form), py_lib_decorates(q, form, "", ""). +lcb_deco_identity(d) :- decorator_builtin("client", bk, d), decorator_transparent(bk). +// the def m enters application stage k unchanged: every decorator applied before it handed it straight back +.decl lcb_deco_blocked(m:symbol, k:number) +lcb_deco_blocked(m, k) :- deco_at(m, k, _), deco_at(m, k2, d2), k2 < k, !lcb_deco_identity(d2), !deco_stage(m, k2 + 1, m). +.decl lcb_deco_obj(m:symbol, c:symbol, f:symbol) +lcb_deco_obj(m, c, f) :- deco_at(m, k, d), !lcb_deco_blocked(m, k), lcb_deco_callable(d, q, form), + py_lib_decorates(q, form, c, f), c != "". + +// ── entries on a library object, and what its fields hand back ────────────── +.decl lcb_lobj_entry(site:symbol, callee:symbol, obj:symbol) +lcb_lobj_entry(site, q, o) :- + call_receiver_object(site, recv), lcb_lobj(recv, c, o), + call_name(site, n), n != "", + !expr_call_candidate(site, _), + q = cat(c, cat(".", n)), lcb_has_rows(q). +// a library object handed to a library callable that calls one of the object's own (library) members +lcb_lobj_entry(site, q2, o) :- + lcb_arg(site, q, r, a), py_lib_callback(q, r, _, "", n, _, _), lcb_lobj(a, c, o), + q2 = cat(c, cat(".", n)), lcb_has_rows(q2). + +// what a library object's field holds that offers member n: the argument its construction was given, or the +// function a decorator wrapped (called as `__call__`) +.decl lcb_lobj_field_target(o:symbol, f:symbol, n:symbol, m:symbol) +lcb_lobj_field_target(al, f, n, m) :- lcb_alloc_field(al, f, a), lcb_member_of_expr(a, n, m). +lcb_lobj_field_target(m, f, "__call__", m) :- lcb_deco_obj(m, _, f), lcb_field_wanted(m, f). +.decl lcb_field_wanted(o:symbol, f:symbol) +lcb_field_wanted(o, f) :- lcb_lobj_entry(_, q, o), py_lib_callback(q, "self", _, f, _, _, _), f != "". +lcb_field_wanted(o, f) :- lcb_arg(site, q, r, a), py_lib_callback(q, r, _, f, _, _, _), f != "", lcb_lobj(a, _, o), + lcb_entry(site, q, _). + +// what the constructor of a library object stored in a field, as the client wrote it at the allocation +.decl lcb_alloc_field(al:symbol, f:symbol, a:symbol) +lcb_alloc_field(al, f, a) :- + lcb_field_wanted(al, f), call_callee_expr(al, callee), lcb_expr_names(callee, c), + py_lib_field_init(c, f, _, pos), call_arg(al, ps, a), to_number(ps) = to_number(pos). +lcb_alloc_field(al, f, a) :- + lcb_field_wanted(al, f), call_callee_expr(al, callee), lcb_expr_names(callee, c), + py_lib_field_init(c, f, pn, _), call_kwarg(al, pn, a). + +// ── the argument the client wrote for a summarised parameter ───────────────── +.decl lcb_arg(site:symbol, callee:symbol, root:symbol, a:symbol) +lcb_arg(site, q, r, a) :- + lcb_entry(site, q, _), py_lib_callback(q, r, pos, _, _, _, _), r != "self", + to_number(pos) >= 0, call_arg(site, ps, a), to_number(ps) = to_number(pos). +lcb_arg(site, q, r, a) :- + lcb_entry(site, q, _), py_lib_callback(q, r, _, _, _, _, _), r != "self", + call_kwarg(site, r, a). + +// ── what a library-held field of a client object holds ─────────────────────── +// lcb_field_holds(T, Field, Expr): an object of client type T was built with Expr as the +// argument the library constructor stores in Field (the construction of T or of a client +// subclass, or T's own super().__init__(...) call) +.decl lcb_ctor_store(t:symbol, f:symbol, param:symbol, pos:symbol) +lcb_ctor_store(t, f, pn, pos) :- lcb_ext_class(t, c), py_lib_field_init(c, f, pn, pos). +.decl lcb_field_holds(t:symbol, f:symbol, e:symbol) +lcb_field_holds(t, f, a) :- + lcb_ctor_store(t, f, _, pos), lcb_self_type(t, s), call_constructs_type(site, s), + call_arg(site, ps, a), to_number(ps) = to_number(pos). +lcb_field_holds(t, f, a) :- + lcb_ctor_store(t, f, pn, _), lcb_self_type(t, s), call_constructs_type(site, s), + call_kwarg(site, pn, a). +lcb_field_holds(t, f, a) :- + lcb_ctor_store(t, f, _, pos), call_recv_kind(site, "SUPER"), call_super_anchor(site, _, t), + call_name(site, "__init__"), call_arg(site, ps, a), to_number(ps) = to_number(pos). +lcb_field_holds(t, f, a) :- + lcb_ctor_store(t, f, pn, _), call_recv_kind(site, "SUPER"), call_super_anchor(site, _, t), + call_name(site, "__init__"), call_kwarg(site, pn, a). + +// ── the client member an object expression offers under a name ─────────────── +// the type the expression has, or a client subclass of it; `__call__` on an expression that +// IS a function or a bound method is that function +.decl lcb_member_of_expr(e:symbol, n:symbol, m:symbol) +lcb_member_of_expr(e, n, m) :- + lcb_wanted(e, n), expr_type("client", e, t), lcb_sub(t, s), mro_lookup("client", s, n, m), + method_owner("client", _, m). +lcb_member_of_expr(e, "__call__", m) :- lcb_wanted(e, "__call__"), expr_denotes_method("client", e, m). +lcb_member_of_expr(e, "__call__", m) :- lcb_wanted(e, "__call__"), expr_names_method("client", e, m). +.decl lcb_sub(t:symbol, s:symbol) +lcb_sub(t, t) :- lcb_wanted(e, _), expr_type("client", e, t). +lcb_sub(t, s) :- lcb_wanted(e, _), expr_type("client", e, t), type_ancestor("client", s, t). + +// the member of a client object's own field, as the client wrote it (`self.app = app`) +.decl lcb_member_of_field(t:symbol, f:symbol, n:symbol, m:symbol) +lcb_member_of_field(t, f, n, m) :- + lcb_wanted_field(t, f, n), field_decl("client", f, owner, _, fh), + (owner = t ; type_ancestor("client", t, owner)), + field_type("client", fh, x), mro_lookup("client", x, n, m), method_owner("client", _, m). + +// demand: only the (expression, member) pairs a summary asks about are looked up +.decl lcb_wanted(e:symbol, n:symbol) +lcb_wanted(a, n) :- lcb_arg(site, q, r, a), py_lib_callback(q, r, _, "", n, _, _), lcb_entry(site, q, _). +lcb_wanted(e, n) :- lcb_entry(_, q, t), t != "", py_lib_callback(q, "self", _, f, n, _, _), f != "", + lcb_field_holds(t, f, e). +lcb_wanted(a, n) :- lcb_alloc_field(al, f, a), lcb_lobj_entry(_, q, al), py_lib_callback(q, "self", _, f, n, _, _). +lcb_wanted(e, n) :- lcb_alloc_field(al, f, e), lcb_lobj(a, _, al), lcb_arg(_, q, r, a), py_lib_callback(q, r, _, f, n, _, _). +lcb_wanted(e, n) :- lcb_arg(site, q, r, a), py_lib_callback(q, r, _, f, n, _, _), f != "", lcb_entry(site, q, _), + expr_type("client", a, t), lcb_field_holds(t, f, e). +.decl lcb_wanted_field(t:symbol, f:symbol, n:symbol) +lcb_wanted_field(s, f, n) :- lcb_entry(_, q, t), t != "", py_lib_callback(q, "self", _, f, n, _, _), f != "", + !contains(".", f), lcb_self_type(t, s). +lcb_wanted_field(t, f, n) :- lcb_arg(site, q, r, a), lcb_entry(site, q, _), py_lib_callback(q, r, _, f, n, _, _), f != "", + !contains(".", f), expr_type("client", a, t). + +// ── the edges ─────────────────────────────────────────────────────────────── +// lcb_hit(Site, Caller, Target, Callee, Root, Path, Member, Via, How): one summary row joined to the client object +// the call actually hands over. +.decl lcb_hit(site:symbol, caller:symbol, m:symbol, q:symbol, r:symbol, f:symbol, n:symbol, via:symbol, how:symbol) +// the receiver itself: a member the client type (or a subclass) defines over the library's +lcb_hit(site, caller, m, q, "self", "", n, via, "receiver") :- + lcb_entry(site, q, t), t != "", site_caller(site, caller), + py_lib_callback(q, "self", _, "", n, via, _), + lcb_self_type(t, s), mro_lookup("client", s, n, m), method_owner("client", _, m). +// a field of the receiver the library constructor filled from a client argument +lcb_hit(site, caller, m, q, "self", f, n, via, "receiver_field") :- + lcb_entry(site, q, t), t != "", site_caller(site, caller), + py_lib_callback(q, "self", _, f, n, via, _), f != "", + lcb_field_holds(t, f, e), lcb_member_of_expr(e, n, m). +// a field of a library object the client holds (built by it, or made by a library decorator around its def) +lcb_hit(site, caller, m, q, "self", f, n, via, "receiver_field") :- + lcb_lobj_entry(site, q, o), site_caller(site, caller), + py_lib_callback(q, "self", _, f, n, via, _), f != "", + lcb_lobj_field_target(o, f, n, m). +lcb_hit(site, caller, m, q, r, f, n, via, "argument_field") :- + lcb_arg(site, q, r, a), site_caller(site, caller), lcb_entry(site, q, _), + py_lib_callback(q, r, _, f, n, via, _), f != "", + lcb_lobj(a, _, o), lcb_lobj_field_target(o, f, n, m). +// a field of the receiver the client itself assigned +lcb_hit(site, caller, m, q, "self", f, n, via, "receiver_field") :- + lcb_entry(site, q, t), t != "", site_caller(site, caller), + py_lib_callback(q, "self", _, f, n, via, _), f != "", + lcb_self_type(t, s), lcb_member_of_field(s, f, n, m). +// an argument, or the callable passed as one +lcb_hit(site, caller, m, q, r, "", n, via, "argument") :- + lcb_arg(site, q, r, a), site_caller(site, caller), + py_lib_callback(q, r, _, "", n, via, _), + lcb_member_of_expr(a, n, m). +// a field of an argument +lcb_hit(site, caller, m, q, r, f, n, via, "argument_field") :- + lcb_arg(site, q, r, a), site_caller(site, caller), + py_lib_callback(q, r, _, f, n, via, _), f != "", + expr_type("client", a, t), lcb_field_holds(t, f, e), lcb_member_of_expr(e, n, m). +lcb_hit(site, caller, m, q, r, f, n, via, "argument_field") :- + lcb_arg(site, q, r, a), site_caller(site, caller), + py_lib_callback(q, r, _, f, n, via, _), f != "", + expr_type("client", a, t), lcb_member_of_field(t, f, n, m). + +// ── WHEN THE OBJECT CANNOT BE TOLD: THE TYPE THE LIBRARY DECLARES ──────────── +// A row whose object the client side could not name (a loader passed through **options, a command a helper +// returns typed only as the library's base) is still a hand-back to SOME object of the type the library declares +// there. The client classes that can be that object are its client subclasses; the one whose member runs is the one +// that defines it. A class-hierarchy answer, used only for a row no client object answered at that site. +.decl lcb_row_hit(site:symbol, q:symbol, r:symbol, f:symbol, n:symbol) +lcb_row_hit(site, q, r, f, n) :- lcb_hit(site, _, _, q, r, f, n, _, _). +.decl lcb_cha_wanted(ty:symbol, n:symbol) +lcb_cha_wanted(ty, n) :- lcb_entry(_, q, _), py_lib_callback(q, r, _, f, n, _, ty), ty != "", (r != "self" ; f != ""). +.decl lcb_cha(ty:symbol, n:symbol, m:symbol) +lcb_cha(ty, n, m) :- lcb_cha_wanted(ty, n), lcb_ext_class(s, ty), mro_lookup("client", s, n, m), method_owner("client", _, m). +lcb_cha(ty, n, m) :- lcb_cha_wanted(ty, n), lcb_ext_class(s, c), py_lib_ancestor(c, ty), + mro_lookup("client", s, n, m), method_owner("client", _, m). + +// lib_callback_edge(Caller, Target, Callee, Member, Via, How): the client method Caller calls the library +// callable Callee, which is summarised (from the library's source) to invoke Member -- written in the library +// function Via -- on an object that resolves to the client method Target. How: receiver | receiver_field | +// argument | argument_field | declared_type. A library's behaviour joined by a member name, not a resolved call. +lib_callback_edge(caller, m, q, n, via, how) :- lcb_hit(_, caller, m, q, _, _, n, via, how). +lib_callback_edge(caller, m, q, n, via, "declared_type") :- + lcb_entry(site, q, _), site_caller(site, caller), + py_lib_callback(q, r, _, f, n, via, ty), ty != "", (r != "self" ; f != ""), + !lcb_row_hit(site, q, r, f, n), lcb_cha(ty, n, m). + +// lib_callback_site(Site, Callee): a client call site the engine left unresolved that enters the library callable +// Callee, which the library's own source declares (a member the client type inherits from its library base, a +// super() call past the client classes, a member of a library object the client built). Not a call to any +// same-named client method, so a consumer must not guess one by name. +lib_callback_site(site, q) :- lcb_entry(site, q, _), q != "". diff --git a/graph/python/libsum/libsum.py b/graph/python/libsum/libsum.py new file mode 100644 index 000000000..e910d123c --- /dev/null +++ b/graph/python/libsum/libsum.py @@ -0,0 +1,1090 @@ +#!/usr/bin/env python3 +"""libsum.py -- what an installed Python library does with the objects a client hands it. + + libsum.py --src --out [--site

[,...]] [--cache ] + +THE GAP THIS CLOSES. The engine reads a dependency's signatures, never its bodies, so a hop the +LIBRARY makes back into client code is invisible: a test client's `get()` calls the `open()` a +client subclass overrides, a CLI runner calls `main()` on the command it was handed, a test client +built around an application calls `app(environ, start_response)` on it. Every one of those is a +library frame between two client frames, and the call graph stops at the first. + +WHAT A SUMMARY SAYS. One row per hand-back a library callable is known to make, rooted at one of +its parameters (or at `self`, the object it was called on): + + py-lib-callback.csv callee root pos path member via type + callee the library callable, canonical dotted name: `pkg.mod.func`, `pkg.mod.Class.method` + (one row per method a class HAS, its inherited ones included), `pkg.mod.Class` for + constructing it + root the parameter the object arrives in, or `self` + pos where the CLIENT writes that argument: the index among the positional arguments of + the call as written (an instance method's `self` is not written), -1 for `self` + path the attribute chain the library follows from that object first ("" for the object + itself, `application` for `self.application`), at most two long + member the method the library invokes on what it reached: a name it calls, or the dunder a + syntax runs (`__call__` for `x()`, `__enter__` for `with x`, `__iter__` for `for`...) + via the library function in which that invocation is written + type the class the LIBRARY declares that object to be (a parameter annotation, a field's), "" when it + declares none: when the client's own object cannot be told, a client subclass of it is the answer + py-lib-field-init.csv class field param pos + constructing `class` stores the argument `param` (written at `pos`) on the new object as + `field`, so a later `self.` hand-back reaches what the client passed + py-lib-ancestor.csv class ancestor + a library class's library ancestors, for the classes the client can name + py-lib-alias.csv alias canonical + a name a package re-exports (`clikit.Command` is `clikit.core.Command`) + py-lib-decorates.csv decorator form class field + applying the library callable as a decorator -- `@decorator` (DIRECT) or `@decorator(...)` (FACTORY) -- + to a function f yields a new `class` whose `field` holds f; class "" when f itself comes back + +HOW, AND WHY NOT IN THE SOLVE. Library source is just Python, so the facts are DERIVED from it by a +small flow-insensitive pass over each function body (which parameter-rooted value meets which call or +protocol syntax), composed to a fixpoint across the library's own calls: `Client.get` calls +`self.open`, `open` calls `run_app(self.application, ...)`, `run_app` calls `app(...)`, so +`Client.get` hands back `__call__` on `self.application`. Nothing is solved over library bodies at +index time: the pass reads only the packages the client imports (and what they import), is cached by +package and version, and writes three small tables. A library written in C has no body to read and +yields nothing; that is a declared blind spot, not a guess. + +WHERE THE LIBRARY IS. The project's own virtual environment (`.venv`, `venv`, `env` in the repository, +else $VIRTUAL_ENV), its stdlib through pyvenv.cfg, or AXIOMCODE_PY_SITE (a comma list of directories) +when set. No environment found means no summaries: the engine then behaves exactly as before. +""" +import argparse, ast, csv, hashlib, json, os, re, sys + +VERSION = '1' +MAXPATH = 2 +DUNDER_BUILTIN = {'len': '__len__', 'str': '__str__', 'repr': '__repr__', 'iter': '__iter__', 'next': '__next__', + 'hash': '__hash__', 'bool': '__bool__', 'format': '__format__', 'reversed': '__reversed__', + 'abs': '__abs__', 'aiter': '__aiter__', 'anext': '__anext__'} +SKIP_DIRS = {'__pycache__', 'tests', 'test', 'testing_support', 'docs', 'examples', 'benchmarks'} + + +# ───────────────────────────────────────────────────────────────────────────── +# where the library is +# ───────────────────────────────────────────────────────────────────────────── +def find_sites(src): + if os.environ.get('AXIOMCODE_PY_SITE'): + return [d for d in os.environ['AXIOMCODE_PY_SITE'].split(',') if os.path.isdir(d)], None + envs = [os.path.join(src, n) for n in ('.venv', 'venv', 'env', '.env')] + if os.environ.get('VIRTUAL_ENV'): + envs.append(os.environ['VIRTUAL_ENV']) + for e in envs: + cfg = os.path.join(e, 'pyvenv.cfg') + if not os.path.isfile(cfg): + continue + sites = [] + for root in (os.path.join(e, 'lib'), os.path.join(e, 'Lib')): + if not os.path.isdir(root): + continue + if os.path.isdir(os.path.join(root, 'site-packages')): + sites.append(os.path.join(root, 'site-packages')) + for d in sorted(os.listdir(root)): + sp = os.path.join(root, d, 'site-packages') + if d.startswith('python') and os.path.isdir(sp): + sites.append(sp) + stdlib = None + try: + kv = dict(l.split('=', 1) for l in open(cfg, encoding='utf-8') if '=' in l) + kv = {k.strip(): v.strip() for k, v in kv.items()} + home, ver = kv.get('home', ''), (kv.get('version_info') or kv.get('version') or '') + mm = '.'.join(ver.split('.')[:2]) + for cand in (os.path.join(os.path.dirname(home), 'lib', f'python{mm}'), os.path.join(home, 'Lib'), + os.path.join(os.path.dirname(home), 'Lib')): + if mm and os.path.isfile(os.path.join(cand, 'contextlib.py')): + stdlib = cand; break + except Exception: + pass + if sites: + return sites, stdlib + return [], None + + +# ───────────────────────────────────────────────────────────────────────────── +# which modules: the client's imports, then what those import, transitively +# ───────────────────────────────────────────────────────────────────────────── +IMPORT_RE = re.compile(r'^\s*(?:from\s+([A-Za-z_][\w.]*)\s+import|import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*))', re.M) + + +def client_imports(src, ir): + """every dotted path the client imports: `from a.b import C` gives a.b.C, `import a.b` gives a.b. From the + parser's import table when the IR is there, else from the source""" + paths = set() + p = os.path.join(ir, 'all-python-imports.csv') if ir else '' + if p and os.path.isfile(p): + try: + with open(p, newline='', encoding='utf-8') as fh: + r = csv.reader(fh, delimiter='\t') + head = next(r) + if 'importedPath' in head: + col = head.index('importedPath') + for row in r: + if len(row) > col and row[col]: + paths.add(row[col]) + return paths + except Exception: + pass + skip = {'.venv', 'venv', 'env', '.env', '.git', 'node_modules', '.tox', '__pycache__', '.axiomcode'} + for d, ds, fs in os.walk(src): + ds[:] = [x for x in ds if x not in skip] + for f in fs: + if not f.endswith('.py'): + continue + try: + tree = ast.parse(open(os.path.join(d, f), encoding='utf-8', errors='replace').read()) + except Exception: + continue + for n in ast.walk(tree): + if isinstance(n, ast.Import): + paths.update(a.name for a in n.names) + elif isinstance(n, ast.ImportFrom) and n.module and not n.level: + paths.update(f'{n.module}.{a.name}' for a in n.names if a.name != '*') + paths.add(n.module) + return paths + + +class Package: + """one importable top-level name: a directory package or a single module file""" + def __init__(self, name, path, base): + self.name, self.path, self.base = name, path, base + + def files(self): + if os.path.isfile(self.path): + yield self.name, self.path + return + for d, ds, fs in os.walk(self.path): + ds[:] = sorted(x for x in ds if x not in SKIP_DIRS and not x.startswith('.')) + for f in sorted(fs): + if not f.endswith('.py'): + continue + rel = os.path.relpath(os.path.join(d, f), self.base)[:-3].replace(os.sep, '.') + if rel.endswith('.__init__'): + rel = rel[:-9] + yield rel, os.path.join(d, f) + + +def locate(name, sites, stdlib): + for base in list(sites) + ([stdlib] if stdlib else []): + p = os.path.join(base, name) + if os.path.isdir(p) and (os.path.isfile(os.path.join(p, '__init__.py')) or base != stdlib): + return Package(name, p, base) + if os.path.isfile(p + '.py'): + return Package(name, p + '.py', base) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# one module: its namespace, its classes, and the events in each function body +# ───────────────────────────────────────────────────────────────────────────── +class Fn: + __slots__ = ('q', 'params', 'pos', 'kind', 'events', 'cls', 'stores', 'ptypes', 'rets', 'local') + + def __init__(self, q, cls): + self.q, self.cls, self.ptypes, self.rets, self.local = q, cls, {}, [], False + self.params, self.pos, self.kind = [], {}, 'function' + self.events = [] # ('use', val, member) | ('call', callee, selfval, {param: [vals]}, [posvals], {kw: [vals]}) + self.stores = [] # (field, val) written on self + + +class Cls: + __slots__ = ('q', 'bases', 'methods', 'mod', 'ftypes') + + def __init__(self, q, mod): + self.q, self.mod, self.bases, self.methods, self.ftypes = q, mod, [], {}, {} + + +def resolve_rel(mod, is_pkg, level, name): + parts = mod.split('.') + if not is_pkg: + parts = parts[:-1] + if level > 1: + parts = parts[:len(parts) - (level - 1)] + return '.'.join([p for p in parts if p] + ([name] if name else [])) + + +class Module: + def __init__(self, name, path, is_pkg): + self.name, self.path, self.is_pkg = name, path, is_pkg + self.ns = {} # local name -> qualified name (a def, a class, an imported thing) + self.fns, self.classes = {}, {} + self.imports = set() + + def load(self): + try: + tree = ast.parse(open(self.path, encoding='utf-8', errors='replace').read()) + except Exception: + return False + self.tree = tree + for st in tree.body: + self._ns_stmt(st) + return True + + def _ns_stmt(self, st): + if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + self.ns[st.name] = f'{self.name}.{st.name}' + elif isinstance(st, ast.ImportFrom): + base = resolve_rel(self.name, self.is_pkg, st.level, st.module or '') if st.level else (st.module or '') + if base: + self.imports.add(base) + for a in st.names: + if a.name != '*': + self.ns[a.asname or a.name] = f'{base}.{a.name}' if base else a.name + elif isinstance(st, ast.Import): + for a in st.names: + self.imports.add(a.name) + if a.asname: + self.ns[a.asname] = a.name + else: + self.ns.setdefault(a.name.split('.')[0], a.name.split('.')[0]) + elif isinstance(st, ast.Assign) and len(st.targets) == 1 and isinstance(st.targets[0], ast.Name): + v = dotted(st.value) + if v and v.split('.')[0] in self.ns: + self.ns.setdefault(st.targets[0].id, self.ns[v.split('.')[0]] + v[len(v.split('.')[0]):]) + elif isinstance(st, (ast.If, ast.Try)): + for b in [st.body, getattr(st, 'orelse', []), getattr(st, 'finalbody', [])] + [h.body for h in getattr(st, 'handlers', [])]: + for s in b: + self._ns_stmt(s) + + def qual(self, n): + return self.ns.get(n) + + def analyse(self): + for st in self.tree.body: + if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef)): + self._fn(st, None) + elif isinstance(st, ast.ClassDef): + c = Cls(f'{self.name}.{st.name}', self) + c.bases = [b for b in (self.qual_expr(x) for x in st.bases) if b] + self.classes[c.q] = c + for s in st.body: + if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name): + t = self.ann_qual(s.annotation) + if t: + c.ftypes[s.target.id] = t + for s in st.body: + if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef)): + f = self._fn(s, c) + c.methods[s.name] = f.q + + def ann_qual(self, ann): + """the class an annotation names: `T`, `pkg.T`, `"T"`, `Optional[T]`, `T | None`""" + if isinstance(ann, ast.Constant) and isinstance(ann.value, str): + try: + ann = ast.parse(ann.value, mode='eval').body + except SyntaxError: + return None + if isinstance(ann, ast.BinOp): + return self.ann_qual(ann.left) + if isinstance(ann, ast.Subscript) and (dotted(ann.value) or '').split('.')[-1] == 'Optional': + return self.ann_qual(ann.slice) + return self.qual_expr(ann) if ann is not None else None + + def qual_expr(self, e): + d = dotted(e.value if isinstance(e, ast.Subscript) else e) + if not d: + return None + head, _, rest = d.partition('.') + q = self.qual(head) + return (q + ('.' + rest if rest else '')) if q else None + + def _fn(self, node, cls): + q = f'{cls.q}.{node.name}' if cls else f'{self.name}.{node.name}' + f = self.signature(node, q, cls) + self.fns[q] = f + Body(self, f, f.params[0] if f.kind in ('method', 'classmethod', 'property') and f.params else None, cls).run(node) + return f + + def signature(self, node, q, cls): + f = Fn(q, cls.q if cls else None) + decos = {dotted(d.func if isinstance(d, ast.Call) else d) for d in node.decorator_list} + a = node.args + names = [x.arg for x in a.posonlyargs + a.args] + if cls is not None: + if 'staticmethod' in decos: + f.kind = 'static' + elif 'classmethod' in decos: + f.kind = 'classmethod' + elif 'property' in decos or any(d and d.endswith(('.setter', '.getter', 'cached_property')) for d in decos): + f.kind = 'property' + else: + f.kind = 'method' + f.params = names + [x.arg for x in a.kwonlyargs] + skip = 1 if f.kind in ('method', 'classmethod', 'property') and names else 0 + for i, n in enumerate(names): + f.pos[n] = i - skip if i >= skip else -1 + for x in a.kwonlyargs: + f.pos[x.arg] = -2 # keyword only: matched by name + for x in a.posonlyargs + a.args + a.kwonlyargs: + if x.annotation is not None: + t = self.ann_qual(x.annotation) + if t: + f.ptypes[x.arg] = t + return f + + +def dotted(e): + if isinstance(e, ast.Name): + return e.id + if isinstance(e, ast.Attribute): + d = dotted(e.value) + return f'{d}.{e.attr}' if d else None + return None + + +class Body: + """a flow-insensitive pass over one function body (nested defs and lambdas included: a closure the + library builds around a parameter is how a decorator or a callback wrapper hands it back)""" + def __init__(self, mod, fn, selfname, cls): + self.mod, self.fn, self.selfname, self.cls = mod, fn, selfname, cls + self.env = {} + self.params = set(fn.params) + + def run(self, node): + for _ in range(2): # twice: a local assigned below its first use + self.fn.events = []; self.fn.stores = []; self.fn.rets = [] + for st in node.body: + self.stmt(st) + return self + + # values: ('p', root, path) | ('ref', qname) | ('new', class, argvals) | ('self', ...) is ('p', 'self', ()) + def val(self, e): + if isinstance(e, ast.Name): + if e.id in self.env: + return self.env[e.id] + if e.id in self.params: + return [('p', 'self' if e.id == self.selfname else e.id, ())] + q = self.mod.qual(e.id) + return [('ref', q)] if q else [] + if isinstance(e, ast.Attribute): + out = [] + for v in self.val(e.value): + if v[0] == 'p' and len(v[2]) < MAXPATH: + out.append(('p', v[1], v[2] + (e.attr,))) + elif v[0] == 'ref': + out.append(('ref', f'{v[1]}.{e.attr}')) + elif v[0] == 'new': + out.append(('field', v, e.attr)) + return out + if isinstance(e, ast.Call): + return self.call(e) + if isinstance(e, (ast.IfExp,)): + self.val(e.test) + return self.val(e.body) + self.val(e.orelse) + if isinstance(e, ast.BoolOp): + out = [] + for x in e.values: + out += self.val(x) + return out + if isinstance(e, ast.NamedExpr): + v = self.val(e.value) + if isinstance(e.target, ast.Name): + self.env[e.target.id] = v + return v + if isinstance(e, ast.Await): + return self.val(e.value) + if isinstance(e, ast.Subscript): + for v in self.val(e.value): + self.use(v, '__getitem__') + self.val(e.slice) + return [] + if isinstance(e, ast.Compare): + self.val(e.left) + for op, c in zip(e.ops, e.comparators): + cv = self.val(c) + if isinstance(op, (ast.In, ast.NotIn)): + for v in cv: + self.use(v, '__contains__') + return [] + if isinstance(e, ast.JoinedStr): + for x in e.values: + if isinstance(x, ast.FormattedValue): + m = {115: '__str__', 114: '__repr__', 97: '__repr__'}.get(x.conversion, '__format__') + for v in self.val(x.value): + self.use(v, m) + return [] + if isinstance(e, ast.Lambda): + self.nested(e.body if isinstance(e.body, list) else [ast.Expr(e.body)], e.args) + return [] + if isinstance(e, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)): + for g in e.generators: + for v in self.val(g.iter): + self.use(v, '__iter__') + for c in g.ifs: + self.val(c) + for x in ([e.key, e.value] if isinstance(e, ast.DictComp) else [e.elt]): + self.val(x) + return [] + for ch in ast.iter_child_nodes(e): + if isinstance(ch, ast.expr): + self.val(ch) + return [] + + def use(self, v, member): + if member == '__call__' and v == ('p', 'self', ()) and self.fn.kind == 'classmethod': + member = '__init__' # cls(...) constructs the class the method was called on + if v[0] == 'p' or v[0] == 'field': + # what the library itself declares the object to be, where it says: a parameter's annotation, a field's + hint = None + if v[0] == 'p': + base = self.cls.q if v[1] == 'self' and self.cls else self.fn.ptypes.get(v[1]) + if base and not v[2]: + hint = ('t', base) + elif base and len(v[2]) == 1: + hint = ('f', base, v[2][0]) + self.fn.events.append(('use', v, member, hint)) + + def call(self, e): + f = e.func + posv = [self.val(a) if not isinstance(a, ast.Starred) else None for a in e.args] + kwv = {k.arg: self.val(k.value) for k in e.keywords if k.arg} + for k in e.keywords: + if not k.arg: + self.val(k.value) + if isinstance(f, ast.Name) and f.id in DUNDER_BUILTIN and f.id not in self.env and not self.mod.qual(f.id) and posv and posv[0]: + for v in posv[0]: + self.use(v, DUNDER_BUILTIN[f.id]) + return [] + if isinstance(f, ast.Name) and f.id == 'super' and not e.args: + return [('super',)] + fd = dotted(f) or '' + if fd.split('.')[-1] == 'cast' and len(posv) == 2 and posv[1]: + return posv[1] # typing.cast(T, x) is x + if fd == 'type' and len(posv) == 1 and posv[0]: + return posv[0] # type(x).__enter__ is looked up on x's class: x's member + if isinstance(f, ast.Attribute): + out = [] + for rv in self.val(f.value): + if rv[0] in ('p', 'field'): + self.use(rv, f.attr) + self.fn.events.append(('call', ('method', rv, f.attr), posv, kwv)) + elif rv[0] == 'super': + self.fn.events.append(('call', ('super', self.cls.q if self.cls else None, f.attr), posv, kwv)) + elif rv[0] == 'new': + self.fn.events.append(('call', ('method', rv, f.attr), posv, kwv)) + elif rv[0] == 'ref': + self.fn.events.append(('call', ('ref', f'{rv[1]}.{f.attr}'), posv, kwv)) + return out + out = [] + for cv in self.val(f): + if cv[0] in ('p', 'field'): + self.use(cv, '__call__') + if cv[0] == 'p' and cv[2]: + # a member read first and called later (`_enter = type(cm).__enter__; _enter(cm)`) + self.use(('p', cv[1], cv[2][:-1]), cv[2][-1]) + elif cv[0] == 'ref': + self.fn.events.append(('call', ('ref', cv[1]), posv, kwv)) + out.append(('new', cv[1], self.args(posv, kwv))) + elif cv[0] == 'closure': + self.fn.events.append(('call', ('ref', cv[1]), posv, kwv)) + out.append(('res', cv[1], self.args(posv, kwv))) + elif cv[0] in ('new', 'res', 'res2'): + out.append(('res2', cv, self.args(posv, kwv))) # calling what a call returned: `command(...)(f)` + return out + + @staticmethod + def args(posv, kwv): + return (tuple(tuple(p) if p is not None else None for p in posv), tuple(sorted((k, tuple(v)) for k, v in kwv.items()))) + + def nested(self, body, args): + saved = set(self.params) + for a in args.posonlyargs + args.args + args.kwonlyargs + [x for x in (args.vararg, args.kwarg) if x]: + self.params.discard(a.arg); self.env.pop(a.arg, None) + for st in body: + self.stmt(st) + self.params = saved + + def assign(self, t, vals): + if isinstance(t, ast.Name): + # flow-insensitive: a parameter rebound keeps what it arrived as (`callback = other.callback`) + base = self.env.get(t.id) or ([('p', 'self' if t.id == self.selfname else t.id, ())] if t.id in self.params else []) + self.env[t.id] = list(dict.fromkeys(base + vals)) + elif isinstance(t, ast.Attribute): + for ov in self.val(t.value): + if ov == ('p', 'self', ()): + for v in vals: + self.fn.stores.append((t.attr, v)) + if self.cls and v[0] == 'p' and not v[2] and v[1] in self.fn.ptypes: + self.cls.ftypes.setdefault(t.attr, self.fn.ptypes[v[1]]) + elif isinstance(t, (ast.Tuple, ast.List)): + for x in t.elts: + self.assign(x, []) + elif isinstance(t, ast.Subscript): + for v in self.val(t.value): + self.use(v, '__setitem__') + + def stmt(self, st): + if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.nested(st.body, st.args) + # and as a callable of its own, so what it RETURNS is known: a decorator factory returns this closure, + # and the closure returns the object it wrapped the decorated function in + sq = f'{self.fn.q}..{st.name}' + sub = self.mod.signature(st, sq, None) + sub.local = True + self.mod.fns[sq] = sub + b = Body(self.mod, sub, None, None) + b.env = {k: list(v) for k, v in self.env.items() if k not in set(sub.params)} + b.run(st) + self.env[st.name] = [('closure', sq)] + return + if isinstance(st, ast.Return): + if st.value is not None: + self.fn.rets.extend(self.val(st.value)) + return + if isinstance(st, ast.ClassDef): + return + if isinstance(st, ast.Assign): + v = self.val(st.value) + for t in st.targets: + self.assign(t, v) + return + if isinstance(st, ast.AnnAssign) and isinstance(st.target, ast.Attribute) and self.cls \ + and isinstance(st.target.value, ast.Name) and st.target.value.id == self.selfname: + t = self.mod.ann_qual(st.annotation) + if t: + self.cls.ftypes[st.target.attr] = t + if isinstance(st, (ast.AnnAssign, ast.AugAssign)): + if st.value is not None: + v = self.val(st.value) + self.assign(st.target, v if isinstance(st, ast.AnnAssign) else []) + return + if isinstance(st, (ast.For, ast.AsyncFor)): + for v in self.val(st.iter): + self.use(v, '__aiter__' if isinstance(st, ast.AsyncFor) else '__iter__') + self.assign(st.target, []) + if isinstance(st, (ast.With, ast.AsyncWith)): + a = isinstance(st, ast.AsyncWith) + for it in st.items: + vs = self.val(it.context_expr) + for v in vs: + self.use(v, '__aenter__' if a else '__enter__'); self.use(v, '__aexit__' if a else '__exit__') + if it.optional_vars is not None: + self.assign(it.optional_vars, []) + if isinstance(st, ast.Delete): + for t in st.targets: + if isinstance(t, ast.Subscript): + for v in self.val(t.value): + self.use(v, '__delitem__') + return + for fld, ch in ast.iter_fields(st): + if fld in ('target', 'targets', 'iter', 'items') and isinstance(st, (ast.For, ast.AsyncFor, ast.With, ast.AsyncWith)): + continue + for c in (ch if isinstance(ch, list) else [ch]): + if isinstance(c, ast.stmt): + self.stmt(c) + elif isinstance(c, ast.expr): + self.val(c) + elif isinstance(c, ast.excepthandler): + for s in c.body: + self.stmt(s) + elif isinstance(c, ast.match_case) if hasattr(ast, 'match_case') else False: + for s in c.body: + self.stmt(s) + + +# ───────────────────────────────────────────────────────────────────────────── +# the program: every analysed module, then the fixpoint across the library's own calls +# ───────────────────────────────────────────────────────────────────────────── +class Program: + def __init__(self): + self.mods, self.fns, self.classes = {}, {}, {} + + def add(self, m): + self.mods[m.name] = m + self.fns.update(m.fns); self.classes.update(m.classes) + + def canon(self, q, seen=None): + """follow a module re-export to the declaration: clikit.Command -> clikit.core.Command""" + if q in self.fns or q in self.classes: + return q + seen = seen or set() + if q in seen or not q: + return None + seen.add(q) + parts = q.split('.') + for i in range(len(parts) - 1, 0, -1): + m = self.mods.get('.'.join(parts[:i])) + if m is None: + continue + nxt = m.qual(parts[i]) + if nxt and nxt != '.'.join(parts[:i + 1]): + return self.canon('.'.join([nxt] + parts[i + 1:]), seen) + if i + 1 < len(parts): # Class.method + c = self.canon('.'.join(parts[:i + 1]), seen) + if c in self.classes: + r = self.lookup(c, parts[i + 1]) + return r if len(parts) == i + 2 else None + return None + return None + + def type_of(self, hint): + """the canonical library class a use hint names: ('t', T) the object's own declared type, ('f', B, f) the + declared type of field f on B (annotated in the class, or assigned from an annotated parameter)""" + if not hint: + return '' + if hint[0] == 't': + c = self.canon(hint[1]) + return c if c in self.classes else '' + b = self.canon(hint[1]) + if b not in self.classes: + return '' + for k in self.mro(b): + t = self.classes[k].ftypes.get(hint[2]) + if t: + c = self.canon(t) or self.canon(f'{self.classes[k].mod.name}.{t}') + return c if c in self.classes else '' + return '' + + def mro(self, c, seen=None): + seen = seen if seen is not None else set() + if c in seen: + return [] + seen.add(c) + out = [c] + k = self.classes.get(c) + for b in (k.bases if k else []): + bc = self.canon(b) + if bc in self.classes: + for x in self.mro(bc, seen): + if x not in out: + out.append(x) + return out + + def lookup(self, c, name): + for k in self.mro(c): + m = self.classes[k].methods.get(name) + if m: + return m + return None + + def solve(self): + S = {q: set() for q in self.fns} # (root, path, member, via) + ST = {c: set() for c in self.classes} # class -> (field, init param) + for f in self.fns.values(): + for ev in f.events: + if ev[0] == 'use' and ev[1][0] == 'p' and (ev[1][1] == 'self' or ev[1][1] in f.pos): + S[f.q].add((ev[1][1], ev[1][2], ev[2], f.q, self.type_of(ev[3]))) + if f.cls and f.q.endswith('.__init__'): + for fld, v in f.stores: + if v[0] == 'p' and v[2] == () and v[1] in f.pos: + ST[f.cls].add((fld, v[1])) + + def init_of(c): + return self.lookup(c, '__init__') + + def stores(c): + out = set() + for k in self.mro(c): + out |= ST.get(k, set()) + return out + + def bind(callee, posv, kwv): + """callee parameter -> the caller's values""" + g = self.fns.get(callee) + if not g: + return {} + out = {} + byp = {i: n for n, i in g.pos.items() if i >= 0} + for i, vs in enumerate(posv): + if vs is not None and i in byp: + out.setdefault(byp[i], []).extend(vs) + for k, vs in kwv.items(): + if k in g.pos: + out.setdefault(k, []).extend(vs) + return out + + def lift(v, path, depth=0): + """a value reached in the caller, followed down `path`: param-rooted values only""" + if v[0] == 'p': + p = v[2] + tuple(path) + return [(v[1], p)] if len(p) <= MAXPATH else [] + if v[0] == 'field' and depth < 3: + return [x for w in field_vals(v[1], v[2]) for x in lift(w, path, depth + 1)] + if v[0] == 'new' and path and depth < 3: + return [x for w in field_vals(v, path[0]) for x in lift(w, path[1:], depth + 1)] + return [] + + def field_vals(newv, fld): + c = self.canon(newv[1]) + if c not in self.classes: + return [] + ini = init_of(c) + if not ini: + return [] + posv = [list(x) if x is not None else None for x in newv[2][0]] + kwv = {k: list(v) for k, v in newv[2][1]} + b = bind(ini, posv, kwv) + return [w for f, pn in stores(c) if f == fld for w in b.get(pn, [])] + + changed, rounds = True, 0 + while changed and rounds < 12: + changed, rounds = False, rounds + 1 + for f in self.fns.values(): + acc = S[f.q]; n0 = len(acc) + for ev in f.events: + if ev[0] == 'use' and ev[1][0] == 'field': + for root, path in lift(ev[1], ()): + if root == 'self' or root in f.pos: + acc.add((root, path, ev[2], f.q, '')) + continue + if ev[0] != 'call': + continue + tgt, posv, kwv = ev[1], ev[2], ev[3] + targets = [] # (callee, selfvals) + if tgt[0] == 'ref': + q = self.canon(tgt[1]) + if q in self.fns: + targets.append((q, [])) + elif q in self.classes and init_of(q): + targets.append((init_of(q), [])) + elif tgt[0] == 'method': + rv, name = tgt[1], tgt[2] + if rv == ('p', 'self', ()) and f.cls: + m = self.lookup(f.cls, name) + if m: + targets.append((m, [rv])) + elif rv[0] == 'p' and rv[2] == () and rv[1] in f.ptypes: + c = self.canon(f.ptypes[rv[1]]) + m = self.lookup(c, name) if c in self.classes else None + if m: + targets.append((m, [rv])) + elif rv[0] == 'p' and len(rv[2]) == 1: + # `self.loader.load(...)`: the field's declared type says whose `load` runs + base = f.cls if rv[1] == 'self' else f.ptypes.get(rv[1]) + c = self.type_of(('f', base, rv[2][0])) if base else '' + m = self.lookup(c, name) if c else None + if m: + targets.append((m, [rv])) + elif rv[0] == 'new': + c = self.canon(rv[1]) + if c in self.classes and self.lookup(c, name): + targets.append((self.lookup(c, name), [rv])) + elif tgt[0] == 'super' and tgt[1]: + for k in self.mro(tgt[1])[1:]: + m = self.classes[k].methods.get(tgt[2]) + if m: + targets.append((m, [('p', 'self', ())])); break + for callee, selfv in targets: + b = bind(callee, posv, kwv) + for (r, p, mem, via, ty) in list(S.get(callee, ())): + src = selfv if r == 'self' else b.get(r, []) + for v in src: + for root, path in lift(v, p): + if root == 'self' or root in f.pos: + acc.add((root, path, mem, via, ty)) + if callee.endswith('.__init__'): + g = self.fns[callee] + # super().__init__(x) / Base.__init__(self, x): what the base stores, this class stores + if f.cls and f.q.endswith('.__init__') and (selfv == [('p', 'self', ())] or tgt[0] == 'super'): + for fld, pn in stores(g.cls): + for v in b.get(pn, []): + if v[0] == 'p' and v[2] == () and v[1] in f.pos and (fld, v[1]) not in ST[f.cls]: + ST[f.cls].add((fld, v[1])); changed = True + if len(acc) != n0: + changed = True + self.S, self.ST = S, ST + return rounds + + def returns(self): + """R[f]: what f's return value is, as far as a decorator needs it -- + ('obj', C, field, param) a new C whose `field` holds f's parameter `param` + ('closure', D) the nested function D (a decorator factory's decorator) + ('param', param) f's own parameter, unchanged (an identity / registering decorator)""" + R = {q: set() for q in self.fns} + + def bindv(g, args): + posv = [list(x) if x is not None else None for x in args[0]] + return self._bind(g, posv, {k: list(v) for k, v in args[1]}) + + def own(f, w): + return w[0] == 'p' and w[2] == () and w[1] in f.pos and w[1] != 'self' + + def apply(f, g, args, out): + b = bindv(g, args) + for d in list(R.get(g, ())): + if d[0] == 'closure': + out.add(d) + elif d[0] in ('obj', 'param'): + for w in b.get(d[-1], []): + if own(f, w): + out.add(d[:-1] + (w[1],)) + elif w[0] == 'closure' and d[0] == 'param': + out.add(w) + + def resolve(f, v, out, depth=0): + if depth > 4: + return + if v[0] == 'closure': + out.add(v) + elif own(f, v): + out.add(('param', v[1])) + elif v[0] == 'new': + c = self.canon(v[1]) + if c in self.classes: + ini = self.lookup(c, '__init__') + if ini: + b = bindv(ini, v[2]) + for fld, pn in self._stores(c): + for w in b.get(pn, []): + if own(f, w): + out.add(('obj', c, fld, w[1])) + elif c in self.fns: + apply(f, c, v[2], out) + elif v[0] == 'res': + apply(f, v[1], v[2], out) + elif v[0] == 'res2': + inner = set() + resolve(f, v[1], inner, depth + 1) + for d in inner: + if d[0] == 'closure': + apply(f, d[1], v[2], out) + + for _ in range(8): + changed = False + for f in self.fns.values(): + acc = R[f.q]; n0 = len(acc) + for v in f.rets: + resolve(f, v, acc) + changed |= len(acc) != n0 + if not changed: + break + self.R = R + + def _bind(self, callee, posv, kwv): + g = self.fns.get(callee) + if not g: + return {} + out = {} + byp = {i: n for n, i in g.pos.items() if i >= 0} + for i, vs in enumerate(posv): + if vs is not None and i in byp: + out.setdefault(byp[i], []).extend(vs) + for k, vs in kwv.items(): + if k in g.pos: + out.setdefault(k, []).extend(vs) + return out + + def _stores(self, c): + out = set() + for k in self.mro(c): + out |= self.ST.get(k, set()) + return out + + def decorates(self, g): + """what applying the callable g as a decorator to a function f yields: (form, class, field) -- DIRECT for + `@g`, FACTORY for `@g(...)`; class '' when f itself comes back (a registering decorator)""" + f = self.fns[g] + first = lambda h: next((n for n, i in self.fns[h].pos.items() if i == 0), None) + out = set() + p0 = first(g) + for d in self.R.get(g, ()): + if d[0] == 'obj' and d[3] == p0: + out.add(('DIRECT', d[1], d[2])) + elif d[0] == 'param' and d[1] == p0: + out.add(('DIRECT', '', '')) + elif d[0] == 'closure': + q0 = first(d[1]) + for e in self.R.get(d[1], ()): + if e[0] == 'obj' and e[3] == q0: + out.add(('FACTORY', e[1], e[2])) + elif e[0] == 'param' and e[1] == q0: + out.add(('FACTORY', '', '')) + return out + + def named(self, imports, lib_pkgs): + """the library classes and functions the client can name: what it imports, what a module it imports + (and that module's direct submodules) declares, and the class a decorator it imports wraps a function + in. Summaries are emitted for these only -- the solve reads what the client can reach, not the venv.""" + out = set() + def add(q): + c = self.canon(q) + if c and c.split('.')[0] in lib_pkgs and '' not in c: + out.add(c) + for p in imports: + add(p) + if p in self.mods: + for m in [p] + [x for x in self.mods if x.startswith(p + '.') and '.' not in x[len(p) + 1:]]: + for local in self.mods[m].ns: + add(f'{m}.{local}') + for q in list(out): + if q in self.fns: + for _, c, _ in self.decorates(q): + if c: + out.add(c) + elif q in self.classes: + for n in {n for k in self.mro(q) for n in self.classes[k].methods}: + m = self.lookup(q, n) + if m: + for _, c, _ in self.decorates(m): + if c: + out.add(c) + return out + + def rows(self, keep): + """flattened rows: every method a class HAS (inherited ones included) and every constructor""" + cb, fi, al, de, an = set(), set(), set(), set(), set() + + def emit(callee_name, fq, kind): + g = self.fns[fq] + for (r, p, mem, via, ty) in self.S.get(fq, ()): + if r == 'self': + pos = -1 + else: + pos = g.pos.get(r, -3) + if pos == -3 or (pos == -1 and kind != 'function'): + continue + cb.add((callee_name, r, pos, '.'.join(p), mem, via, ty)) + for q, g in self.fns.items(): + if g.cls is None and not g.local and keep(q): + emit(q, q, 'function') + for form, c, fld in self.decorates(q): + de.add((q, form, c, fld)) + for c in self.classes: + if not keep(c): + continue + names = set() + for k in self.mro(c): + names |= set(self.classes[k].methods) + for n in names: + m = self.lookup(c, n) + if m and self.fns[m].kind != 'property': + emit(f'{c}.{n}', m, 'method' if self.fns[m].kind != 'static' else 'function') + for form, k, fld in self.decorates(m): + de.add((f'{c}.{n}', form, k, fld)) + for a in self.mro(c)[1:]: + an.add((c, a)) + ini = self.lookup(c, '__init__') + if ini: + emit(c, ini, 'ctor') + g = self.fns[ini] + for fld, pn in set().union(*[self.ST.get(k, set()) for k in self.mro(c)]): + p = g.pos.get(pn, -3) + if p != -3: + fi.add((c, fld, pn, p)) + if p == 0: + de.add((c, 'DIRECT', c, fld)) # `@C def f`: C(f) holds f + for mname, m in self.mods.items(): + for local, q in m.ns.items(): + a = f'{mname}.{local}' + if a == q: + continue + c = self.canon(a) + if c and c != a and keep(c): + al.add((a, c)) + return cb, fi, al, de, an + + +def write(path, head, rows): + tmp = path + '.tmp' + with open(tmp, 'w', newline='', encoding='utf-8') as fh: + w = csv.writer(fh, delimiter='\t', lineterminator='\n') + w.writerow(head) + for r in sorted(rows, key=lambda x: tuple(str(c) for c in x)): + w.writerow(r) + os.replace(tmp, path) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--src', required=True); ap.add_argument('--out', required=True) + ap.add_argument('--ir', default=None); ap.add_argument('--stats', action='store_true') + A = ap.parse_args() + os.makedirs(A.out, exist_ok=True) + heads = (('py-lib-callback.csv', ('callee', 'root', 'pos', 'path', 'member', 'via', 'type')), + ('py-lib-field-init.csv', ('class', 'field', 'param', 'pos')), + ('py-lib-alias.csv', ('alias', 'canonical')), + ('py-lib-decorates.csv', ('decorator', 'form', 'class', 'field')), + ('py-lib-ancestor.csv', ('class', 'ancestor'))) + sites, stdlib = find_sites(os.path.abspath(A.src)) + if not sites: + for f, h in heads: + write(os.path.join(A.out, f), h, []) + print('▶ library callbacks: no Python environment found in the repository (.venv, venv, $VIRTUAL_ENV, ' + 'AXIOMCODE_PY_SITE); none summarised') + return + imports = client_imports(A.src, A.ir or A.out) + # CACHED BY WHAT DECIDES THE ANSWER: this file, the client's import paths, and the installed distributions + # (name and version from each *.dist-info) of every environment read. An edit that adds no import -- every + # refresh but a few -- reuses the tables instead of reading the library again. + h = hashlib.sha256() + h.update(open(os.path.abspath(__file__), 'rb').read()) + h.update('\n'.join(sorted(imports)).encode()) + for d in sites + ([stdlib] if stdlib else []): + h.update(d.encode()) + try: + h.update('\n'.join(sorted(x for x in os.listdir(d) if x.endswith(('.dist-info', '.egg-info', '.pth')))).encode()) + except OSError: + pass + cache = os.path.join(os.environ.get('XDG_CACHE_HOME') or os.path.join(os.path.expanduser('~'), '.cache'), + 'axiomcode', 'libsum', h.hexdigest()[:32]) + if all(os.path.isfile(os.path.join(cache, f)) for f, _ in heads): + import shutil + for f, _ in heads: + shutil.copyfile(os.path.join(cache, f), os.path.join(A.out, f)) + try: + os.utime(cache) + print(open(os.path.join(cache, 'summary.txt'), encoding='utf-8').read().strip() + ' (cached)') + except OSError: + print('▶ library callbacks: reused the cached summaries') + return + roots = {p.split('.')[0] for p in imports} + prog, todo, seen, client_pkgs = Program(), sorted(roots), set(), set() + # the client's own package installed in editable mode is not a library: its top-level names in the source tree + for n in os.listdir(A.src): + if os.path.isfile(os.path.join(A.src, n, '__init__.py')) or n.endswith('.py'): + client_pkgs.add(n[:-3] if n.endswith('.py') else n) + for sub in ('src', 'lib'): + d = os.path.join(A.src, sub) + if os.path.isdir(d): + for n in os.listdir(d): + if os.path.isfile(os.path.join(d, n, '__init__.py')): + client_pkgs.add(n) + nfiles = 0 + while todo: + r = todo.pop() + if r in seen or r in client_pkgs: + continue + seen.add(r) + pkg = locate(r, sites, stdlib) + if not pkg: + continue + for modname, path in pkg.files(): + m = Module(modname, path, path.endswith('__init__.py')) + if not m.load(): + continue + nfiles += 1 + m.analyse() + del m.tree + prog.add(m) + for imp in m.imports: + t = imp.split('.')[0] + if t and t not in seen: + todo.append(t) + rounds = prog.solve() + prog.returns() + lib_pkgs = {p for p in seen if p not in client_pkgs} + named = prog.named(imports, lib_pkgs) + keep = lambda q: q in named + cb, fi, al, de, an = prog.rows(keep) + for (f, h), rows in zip(heads, (cb, fi, al, de, an)): + write(os.path.join(A.out, f), h, rows) + line = (f'▶ library callbacks: {len(cb)} hand-backs, {len(fi)} constructor stores, {len(al)} re-exports, ' + f'{len(de)} decorators, from {nfiles} library modules ({len(lib_pkgs)} packages, {rounds} rounds)') + print(line) + try: + import shutil + tmp = cache + f'.tmp{os.getpid()}' + os.makedirs(tmp, exist_ok=True) + for f, _ in heads: + shutil.copyfile(os.path.join(A.out, f), os.path.join(tmp, f)) + open(os.path.join(tmp, 'summary.txt'), 'w', encoding='utf-8').write(line + '\n') + os.replace(tmp, cache) + root = os.path.dirname(cache) # keep the 64 most recently used + old = sorted((os.path.getmtime(os.path.join(root, x)), x) for x in os.listdir(root) if '.tmp' not in x) + for _, x in old[:-64]: + shutil.rmtree(os.path.join(root, x), ignore_errors=True) + except OSError: + pass + + +if __name__ == '__main__': + main() diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 6f1fd8647..4617c7f1e 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -668,3 +668,11 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +// staged beside the client IR by graph/python/libsum/libsum.py (framework-behavior/library-callbacks.dl) +.decl py_lib_callback(callee:symbol, root:symbol, pos:symbol, path:symbol, member:symbol, via:symbol, type:symbol) +.decl py_lib_ancestor(cls:symbol, ancestor:symbol) +.decl py_lib_field_init(cls:symbol, field:symbol, param:symbol, pos:symbol) +.decl py_lib_alias(alias:symbol, canon:symbol) +.decl lib_callback_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) +.decl lib_callback_site(c0:symbol,c1:symbol) +.decl py_lib_decorates(decorator:symbol, form:symbol, cls:symbol, field:symbol) diff --git a/graph/python/souffle/export_manifest.tsv b/graph/python/souffle/export_manifest.tsv index 758e6d9ee..19aaa3d2f 100644 --- a/graph/python/souffle/export_manifest.tsv +++ b/graph/python/souffle/export_manifest.tsv @@ -91,3 +91,5 @@ remote_unserved remote-unserved.csv remote_unsent remote-unsent.csv framework_edge framework-edge.csv framework_unjoined framework-unjoined.csv +lib_callback_edge lib-callback-edge.csv +lib_callback_site lib-callback-site.csv diff --git a/graph/python/templates/client-extra.map b/graph/python/templates/client-extra.map new file mode 100644 index 000000000..18f5076fd --- /dev/null +++ b/graph/python/templates/client-extra.map @@ -0,0 +1,12 @@ +# Soufflé input-relation -> CSV basename for facts staged BESIDE the client IR that are not parser facts. +# Same format and staging as client-ir.map (absent file = empty relation, so every project compiles one program), +# but outside the frozen parser schema: no decls_base.dl row, no lib.map counterpart (a library's own summary of +# its dependencies is not staged), declared in decls_all.dl instead. +# +# The library callback summaries graph/python/libsum/libsum.py writes from the project's installed dependencies +# (framework-behavior/library-callbacks.dl). Absent = empty = no library hand-backs, exactly as before. +py_lib_callback py-lib-callback +py_lib_field_init py-lib-field-init +py_lib_alias py-lib-alias +py_lib_decorates py-lib-decorates +py_lib_ancestor py-lib-ancestor diff --git a/graph/test/python/tools/literal_gate.py b/graph/test/python/tools/literal_gate.py index db44b130b..0e7af6f71 100644 --- a/graph/test/python/tools/literal_gate.py +++ b/graph/test/python/tools/literal_gate.py @@ -83,7 +83,12 @@ # unresolved_value_callee's second column is `binding`: WHAT the callee of an # unresolved call is ("parameter", "loop_variable", "field", ...), the same words # the JavaScript rules write. Written into the output and never joined on. - "unresolved_value_callee(") + "unresolved_value_callee(", + # lib_callback_edge's How column (receiver / receiver_field / argument / argument_field) says + # where on the call the handed-back object arrived. Written into the output by + # framework-behavior/library-callbacks.dl and never joined on; every name that DOES decide a + # library hand-back comes from the tables graph/python/libsum derives from the library's source. + "lib_callback_edge(", "lcb_hit(") GROUND_FACT = re.compile(r'^[a-z_]+\((?:\s*"[^"]*"\s*,?)+\)\.\s*$') From 9938c303631e02141b5498fa50525643e8f9d2a5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:13:04 -0700 Subject: [PATCH 38/40] feat(impact): walk a library calling back into the client, as its own hop `library callback` impact reads the Python engine's lib_callback_edge as fw_edge "library callback": a test that drives a library now reaches the client member the library hands back to, with the rung `library callback` (ranked with `registered`: a library's behaviour joined by a member name, never a resolved call). lib_callback_site marks a call site the engine knows enters a library callable as a library receiver, so it is never a by-name seed: a test client's `client.put()` no longer reaches the project's own `put` by name. tests/cases/python/library-calls-back: a fake site-packages with a test client, a CLI runner and command decorator, a template environment whose loader arrives through **options and a function calling a member on its argument; each positive check fails on the base engine. Controls: the same member name on a class never handed to a library, an object handed to a library function that never calls that member, a same-named member on a class that is not a subclass of the declared library type. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 11 +- .../skills/axiomcode/scripts/axiomcode-impact | 18 ++- .../skills/axiomcode/scripts/dl/impact.dl | 7 + .../skills/axiomcode/scripts/graph_sql.py | 3 + .../site-packages/clikit/__init__.py | 1 + .../python3.12/site-packages/clikit/core.py | 28 ++++ .../site-packages/clikit/testing.py | 6 + .../site-packages/hooks/__init__.py | 6 + .../python3.12/site-packages/tmpl/__init__.py | 15 +++ .../site-packages/webkit/__init__.py | 1 + .../site-packages/webkit/testing.py | 14 ++ .../library-calls-back/.venv/pyvenv.cfg | 2 + .../python/library-calls-back/app/__init__.py | 0 .../python/library-calls-back/app/cli.py | 12 ++ .../python/library-calls-back/app/events.py | 22 ++++ .../python/library-calls-back/app/render.py | 20 +++ .../python/library-calls-back/app/testing.py | 4 + .../python/library-calls-back/app/web.py | 22 ++++ .../cases/python/library-calls-back/case.json | 121 ++++++++++++++++++ .../library-calls-back/tests/__init__.py | 0 .../library-calls-back/tests/test_cli.py | 18 +++ .../library-calls-back/tests/test_events.py | 5 + .../library-calls-back/tests/test_render.py | 5 + .../library-calls-back/tests/test_web.py | 16 +++ 24 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/__init__.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/core.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/testing.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/hooks/__init__.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/tmpl/__init__.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/__init__.py create mode 100644 tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/testing.py create mode 100644 tests/cases/python/library-calls-back/.venv/pyvenv.cfg create mode 100644 tests/cases/python/library-calls-back/app/__init__.py create mode 100644 tests/cases/python/library-calls-back/app/cli.py create mode 100644 tests/cases/python/library-calls-back/app/events.py create mode 100644 tests/cases/python/library-calls-back/app/render.py create mode 100644 tests/cases/python/library-calls-back/app/testing.py create mode 100644 tests/cases/python/library-calls-back/app/web.py create mode 100644 tests/cases/python/library-calls-back/case.json create mode 100644 tests/cases/python/library-calls-back/tests/__init__.py create mode 100644 tests/cases/python/library-calls-back/tests/test_cli.py create mode 100644 tests/cases/python/library-calls-back/tests/test_events.py create mode 100644 tests/cases/python/library-calls-back/tests/test_render.py create mode 100644 tests/cases/python/library-calls-back/tests/test_web.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index a12843281..30d4be7d0 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -507,9 +507,14 @@ def nearest(cands): # the candidates enclosing no other candidat def library_receiver_sites(q): - """the ids of the unresolved call sites whose receiver is a package's value. q(sql, params) -> rows.""" - if not list(q("SELECT 1 FROM sqlite_master WHERE name = 'ext_library_receiver'", ())): return set() - return {r[0] for r in q("SELECT DISTINCT c0 FROM ext_library_receiver", ())} + """the ids of the unresolved call sites whose receiver is a package's value, or that the engine knows enter a + library callable (Python ext_lib_callback_site: a member a client type inherits from its library base, whose + source declares it). q(sql, params) -> rows.""" + out = set() + for t in ('ext_library_receiver', 'ext_lib_callback_site'): + if list(q("SELECT 1 FROM sqlite_master WHERE name = ?", (t,))): + out |= {r[0] for r in q(f"SELECT DISTINCT c0 FROM {t}", ())} + return out # ── a MOCKED TYPE: a test class that holds a mock of T never runs T's methods ──────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 75338e6d8..871cbdf6d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1339,7 +1339,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '67' # 67: libcb, a library summarised to call a client member back (Python library-callbacks.dl), walked as fw_edge "library callback"; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -2059,6 +2059,13 @@ class Impact: g.q("SELECT c0, c1, c2, c3, c4 FROM ext_framework_edge") if r[0] and r[1] and r[0] != r[1]}) W('framework', fw) + # A LIBRARY CALLING BACK INTO THE CLIENT (Python framework-behavior/library-callbacks.dl): walked by the closure as + # its own hop, `library callback`, so a test that drives the library reaches the client member it hands back to. + libcb = [] + if g.has('ext_lib_callback_edge'): + libcb = sorted({(r[0], r[1], r[2], r[3]) for r in g.q("SELECT c0, c1, c2, c3 FROM ext_lib_callback_edge") + if r[0] and r[1] and r[0] != r[1]}) + W('libcb', libcb) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); P.replace_file(t, stamp); return D # ── one query: the target facts, the text-level facts, one Soufflé run ────────────────────────────────────── @@ -2955,7 +2962,10 @@ def main(argv): 'remote': 'remote', # a servlet filter the test's context holds, run on each request the test sends (ax_registration.filter_links): # the container calls it, no call site does, so it is a registration like a callback's - 'filter': 'registered'} + 'filter': 'registered', + # a library summarised (from its source) to call a client member back: a member name on a type the client + # handed over, not a resolved call (framework-behavior/library-callbacks.dl) + 'library callback': 'library callback'} # The numbers that used to stand here (sound 0.645, one of a set 0.414, by key 0.396, fixture 0.233, by name # 0.133) were measured against a truth set that was SHORT: a pytest file which fails to COLLECT is emitted in # junit XML with classname="" and the module in `name`, the oracle read classname alone, and so every @@ -2993,7 +3003,7 @@ def main(argv): # is strong. It sits below `one of a set` all the same, because it is not a resolved call and should not borrow # the word that means one. CERT_RANK = {'sound': 0, 'defines': 1, 'at import': 2, 'one of a set': 3, 'dispatch': 4, 'registered': 5, - 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, + 'library callback': 5, 'remote': 6, 'spawns': 7, 'by key': 8, 'decorator by name': 9, 'protocol': 10, 'fixture': 11, 'by name': 12} # `remote` (#1293) sits just above `by key`: both join two ends on a string, but the engine matched a remote # hop's whole route and verb on both ends, where a key join is this file's own rules matching one literal. Not @@ -3068,7 +3078,7 @@ def main(argv): # `decorator by name` is the rules' own hop from a decorated def to the decorator it names # (dl/impact.dl fw_edge), asserted exactly as `decorator` is; leaving it out printed "✗ not found" on # every answer whose test route passes through a project decorator - if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name', 'remote', 'spawns', 'filter') for x, t in parent.get(a, [])): bad += 1 + if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name', 'remote', 'spawns', 'filter', 'library callback') for x, t in parent.get(a, [])): bad += 1 asserted = collections.defaultdict(set) # the callee each direct row claims an edge to for c, callee in {(x[0], x[1]) for x in res['direct_edge']}: asserted[c].add(callee) for c, role, why, cert, loc, n in D: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index e831a6a79..9a9028642 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1082,6 +1082,13 @@ fw_edge(a, b, "remote") :- remote(a, b, _, _, _), a != b. // the test methods that send a request get the hop; its own rung, `registered`, never a call chain. .decl filter_run(a:symbol, b:symbol, how:symbol) .input filter_run fw_edge(a, b, "filter") :- filter_run(a, b, _), a != b. +// A LIBRARY CALLING BACK INTO THE CLIENT. The engine's lib_callback_edge (Python framework-behavior/library-callbacks.dl): +// a call into an installed library that the library's own source is summarised to hand back to a client member -- a +// test client's get() running the open() a client subclass overrides, a runner running the command it was handed. +// No call site names the client member, so without this hop a test that drives the library never reached it. Its own +// rung, `library callback`: a library's behaviour joined by a member name, never a resolved call. +.decl libcb(a:symbol, b:symbol, callee:symbol, member:symbol) .input libcb +fw_edge(a, b, "library callback") :- libcb(a, b, _, _), a != b. // and the filter's own answer names who runs it, worded as a framework hop (the `framework` rule above) direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", cat(how, " [registered]")), "framework", "", 0) :- target(q, "method", m, _), filter_run(c, m, how), c != m. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index ecf87c756..94bc824e7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -3561,6 +3561,9 @@ def _has_framework_hops(q, at=None, site_file=None): try: if _has(q, 'ext_decorated_name_target') and q("SELECT 1 FROM ext_decorated_name_target WHERE c0 <> c1 LIMIT 1"): return True + # fw_edge "library callback": a library summarised to call a client member back (library-callbacks.dl) + if _has(q, 'ext_lib_callback_edge') and q("SELECT 1 FROM ext_lib_callback_edge WHERE c0 <> c1 LIMIT 1"): + return True if _has(q, 'symbols') and q("SELECT 1 FROM symbols WHERE file LIKE '%conftest.py' AND method_id IS NOT NULL LIMIT 1"): return True # a fixture injected by name anywhere, conftest or not: the rules credit it only to the tests that request diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/__init__.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/__init__.py new file mode 100644 index 000000000..031798a83 --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/__init__.py @@ -0,0 +1 @@ +from .core import Command, command diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/core.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/core.py new file mode 100644 index 000000000..b376dd56e --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/core.py @@ -0,0 +1,28 @@ +class Context: + def __init__(self, command): + self.command = command + + def invoke(self, callback): + return callback() + + +class Command: + def __init__(self, name, callback=None): + self.name = name + self.callback = callback + + def make_context(self, args): + return Context(self) + + def invoke(self, ctx: Context): + return ctx.invoke(self.callback) + + def main(self, args): + ctx = self.make_context(args) + return self.invoke(ctx) + + +def command(name=None): + def decorator(f): + return Command(name or f.__name__, callback=f) + return decorator diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/testing.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/testing.py new file mode 100644 index 000000000..0f5f711e8 --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/clikit/testing.py @@ -0,0 +1,6 @@ +class CliRunner: + def __init__(self, charset="utf-8"): + self.charset = charset + + def invoke(self, cli, args=None): + return cli.main(args or []) diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/hooks/__init__.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/hooks/__init__.py new file mode 100644 index 000000000..09edbf15c --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/hooks/__init__.py @@ -0,0 +1,6 @@ +def fire(listener): + listener.on_event("x") + + +def keep(listener): + return [listener] diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/tmpl/__init__.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/tmpl/__init__.py new file mode 100644 index 000000000..397ea6420 --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/tmpl/__init__.py @@ -0,0 +1,15 @@ +class BaseLoader: + def load(self, env, name): + return self.get_source(env, name) + + def get_source(self, env, name): + raise NotImplementedError + + +class Environment: + def __init__(self, loader: "BaseLoader" = None, **options): + self.loader = loader + self.options = options + + def get_template(self, name): + return self.loader.load(self, name) diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/__init__.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/__init__.py new file mode 100644 index 000000000..2d2fd0028 --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/__init__.py @@ -0,0 +1 @@ +from .testing import Client diff --git a/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/testing.py b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/testing.py new file mode 100644 index 000000000..10ef5bbea --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/lib/python3.12/site-packages/webkit/testing.py @@ -0,0 +1,14 @@ +def run_app(app, environ): + return app(environ, None) + + +class Client: + def __init__(self, application, use_cookies=True): + self.application = application + self.use_cookies = use_cookies + + def open(self, path): + return run_app(self.application, {"PATH": path}) + + def get(self, path): + return self.open(path) diff --git a/tests/cases/python/library-calls-back/.venv/pyvenv.cfg b/tests/cases/python/library-calls-back/.venv/pyvenv.cfg new file mode 100644 index 000000000..5c46911ce --- /dev/null +++ b/tests/cases/python/library-calls-back/.venv/pyvenv.cfg @@ -0,0 +1,2 @@ +home = /nonexistent/bin +version_info = 3.12.0 diff --git a/tests/cases/python/library-calls-back/app/__init__.py b/tests/cases/python/library-calls-back/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/library-calls-back/app/cli.py b/tests/cases/python/library-calls-back/app/cli.py new file mode 100644 index 000000000..3199a2c35 --- /dev/null +++ b/tests/cases/python/library-calls-back/app/cli.py @@ -0,0 +1,12 @@ +import clikit +from clikit.core import Command + + +class AppGroup(Command): + def make_context(self, args): + return super().make_context(args) + + +@clikit.command() +def greet(): + return "hi" diff --git a/tests/cases/python/library-calls-back/app/events.py b/tests/cases/python/library-calls-back/app/events.py new file mode 100644 index 000000000..992ff07df --- /dev/null +++ b/tests/cases/python/library-calls-back/app/events.py @@ -0,0 +1,22 @@ +from hooks import fire, keep + + +class Listener: + def on_event(self, e): + return e + + +class Kept: + def on_event(self, e): + return e + + +class Other: + def on_event(self, e): + return e + + +def emit(): + fire(Listener()) + keep(Kept()) + return Other() diff --git a/tests/cases/python/library-calls-back/app/render.py b/tests/cases/python/library-calls-back/app/render.py new file mode 100644 index 000000000..4f89f726c --- /dev/null +++ b/tests/cases/python/library-calls-back/app/render.py @@ -0,0 +1,20 @@ +from tmpl import BaseLoader, Environment + + +class AppLoader(BaseLoader): + def get_source(self, env, name): + return name + + +class OtherLoader: + def get_source(self, env, name): + return name + + +def make_env(opts): + return Environment(**opts) + + +def render(opts): + env = make_env(opts) + return env.get_template("index") diff --git a/tests/cases/python/library-calls-back/app/testing.py b/tests/cases/python/library-calls-back/app/testing.py new file mode 100644 index 000000000..6e2e205ba --- /dev/null +++ b/tests/cases/python/library-calls-back/app/testing.py @@ -0,0 +1,4 @@ +# a project module whose name is also the last segment of a dependency's module (`clikit.testing`): an import of the +# dependency's module must not resolve here +def make_args(): + return [] diff --git a/tests/cases/python/library-calls-back/app/web.py b/tests/cases/python/library-calls-back/app/web.py new file mode 100644 index 000000000..86a22ceb4 --- /dev/null +++ b/tests/cases/python/library-calls-back/app/web.py @@ -0,0 +1,22 @@ +from webkit import Client + + +class App: + def __call__(self, environ, start): + return self.dispatch(environ) + + def dispatch(self, environ): + return "ok" + + def test_client(self): + return AppClient(self) + + +class AppClient(Client): + def open(self, path): + return super().open(path) + + +class Unrelated: + def open(self, path): + return path diff --git a/tests/cases/python/library-calls-back/case.json b/tests/cases/python/library-calls-back/case.json new file mode 100644 index 000000000..3019fe7e7 --- /dev/null +++ b/tests/cases/python/library-calls-back/case.json @@ -0,0 +1,121 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a test client's get() runs the open() the client subclass overrides: the library calls it back, no call site names it", + "run": [ + "impact", + "AppClient.open", + "--tests" + ], + "want": [ + "tests/test_web.py", + "library callback" + ] + }, + { + "why": "the test client then calls the application it was built around (stored by the library's constructor, called as app(environ, start))", + "run": [ + "impact", + "App.dispatch", + "--tests" + ], + "want": [ + "tests/test_web.py" + ] + }, + { + "why": "CONTROL: an open() on a class no library calls back is reached only by its own test", + "run": [ + "impact", + "Unrelated.open", + "--tests" + ], + "want": [ + "test_unrelated" + ], + "avoid": [ + "test_index" + ] + }, + { + "why": "a CLI runner's invoke(cli) calls main() on the command, whose make_context() the client overrides", + "run": [ + "impact", + "AppGroup.make_context", + "--tests" + ], + "want": [ + "tests/test_cli.py", + "test_group" + ] + }, + { + "why": "a library function calls a member on the object handed to it", + "run": [ + "impact", + "Listener.on_event", + "--tests" + ], + "want": [ + "tests/test_events.py" + ] + }, + { + "why": "CONTROL: an object handed to a library function that never calls that member gains nothing", + "run": [ + "impact", + "Kept.on_event", + "--tests" + ], + "avoid": [ + "tests/test_events.py" + ] + }, + { + "why": "CONTROL: an object with the same member name that is never handed to the library gains nothing", + "run": [ + "impact", + "Other.on_event", + "--tests" + ], + "avoid": [ + "tests/test_events.py" + ] + }, + { + "why": "a function a library decorator wraps into a command object is called back when a runner invokes that object", + "run": [ + "impact", + "greet", + "--tests" + ], + "want": [ + "test_greet" + ] + }, + { + "why": "a field the library declares as its base type, filled where the client cannot be followed (**options): the client subclass of that type that defines the member is the one called back", + "run": [ + "impact", + "AppLoader.get_source", + "--tests" + ], + "want": [ + "tests/test_render.py" + ] + }, + { + "why": "CONTROL: a class with the same member that is not a subclass of the declared library type gains nothing", + "run": [ + "impact", + "OtherLoader.get_source", + "--tests" + ], + "avoid": [ + "tests/test_render.py" + ] + } + ] +} diff --git a/tests/cases/python/library-calls-back/tests/__init__.py b/tests/cases/python/library-calls-back/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/library-calls-back/tests/test_cli.py b/tests/cases/python/library-calls-back/tests/test_cli.py new file mode 100644 index 000000000..e76e69984 --- /dev/null +++ b/tests/cases/python/library-calls-back/tests/test_cli.py @@ -0,0 +1,18 @@ +import pytest +from clikit.testing import CliRunner + +from app.cli import AppGroup, greet + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_group(runner): + cli = AppGroup("app") + runner.invoke(cli, []) + + +def test_greet(runner): + runner.invoke(greet) diff --git a/tests/cases/python/library-calls-back/tests/test_events.py b/tests/cases/python/library-calls-back/tests/test_events.py new file mode 100644 index 000000000..909473568 --- /dev/null +++ b/tests/cases/python/library-calls-back/tests/test_events.py @@ -0,0 +1,5 @@ +from app.events import emit + + +def test_emit(): + emit() diff --git a/tests/cases/python/library-calls-back/tests/test_render.py b/tests/cases/python/library-calls-back/tests/test_render.py new file mode 100644 index 000000000..f6d8a1e33 --- /dev/null +++ b/tests/cases/python/library-calls-back/tests/test_render.py @@ -0,0 +1,5 @@ +from app.render import AppLoader, render + + +def test_render(): + assert render({"loader": AppLoader()}) == "index" diff --git a/tests/cases/python/library-calls-back/tests/test_web.py b/tests/cases/python/library-calls-back/tests/test_web.py new file mode 100644 index 000000000..c6e4d1934 --- /dev/null +++ b/tests/cases/python/library-calls-back/tests/test_web.py @@ -0,0 +1,16 @@ +import pytest + +from app.web import App, Unrelated + + +@pytest.fixture +def client(): + return App().test_client() + + +def test_index(client): + assert client.get("/") == "ok" + + +def test_unrelated(): + assert Unrelated().open("/") == "/" From cf5b8bd7f2eac7e350c3daaea2d31bf601fe03c5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:25:38 -0700 Subject: [PATCH 39/40] feat(path, impact): a library's hand-back is a hop path walks and a dependent impact names path adds lib_callback_edge to the hops no call site expresses, worded `library_callback via -> ` and verified against the engine's table, so a test reaches the application its test client calls; impact lists the client method that calls the library as a [framework] dependent of the member handed back ("a library it calls hands this back ( calls )"), which is what path's note points at. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/axiomcode-path | 8 ++++++++ .../axiomcode/skills/axiomcode/scripts/dl/impact.dl | 3 +++ tests/cases/python/library-calls-back/case.json | 11 +++++++++++ 3 files changed, 22 insertions(+) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 51c36cbd1..502679789 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1069,6 +1069,12 @@ class G: for a, b, how, where, conf in self.q(f"SELECT c0, c1, c2, c3, c4 FROM {table}"): if a == b or a not in self.sym or b not in self.sym: continue self.OUTSIDE.setdefault((a, b, tier), []).append((how, where, conf)) + # a library summarised (from its source) to call a client member back (Python framework-behavior/ + # library-callbacks.dl): `impact` walks it as the `library callback` hop, so the chain search does too + if self.has('ext_lib_callback_edge'): + for a, b, callee, member, via, how in self.q("SELECT c0, c1, c2, c3, c4, c5 FROM ext_lib_callback_edge"): + if a == b or a not in self.sym or b not in self.sym: continue + self.OUTSIDE.setdefault((a, b, 'framework'), []).append(('library_callback', f'{callee} -> {member}', how)) # a servlet filter a test's context holds, run on each request the test sends (ax_registration.filter_links) try: import ax_registration @@ -1168,6 +1174,8 @@ def outside_hop_holds(g, a, b, t): table = 'ext_remote_edge' if t == 'remote' else 'ext_framework_edge' if g.has(table) and g.q(f"SELECT 1 FROM {table} WHERE c0 = ? AND c1 = ? LIMIT 1", a, b): return True if t != 'framework': return False + if g.has('ext_lib_callback_edge') and g.q("SELECT 1 FROM ext_lib_callback_edge WHERE c0 = ? AND c1 = ? LIMIT 1", a, b): + return True import ax_registration links = getattr(g, '_filter_links', None) if links is None: links = g._filter_links = {(x, y) for x, y, _h in ax_registration.filter_links(g.q)} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 9a9028642..34dd38b51 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1089,6 +1089,9 @@ fw_edge(a, b, "filter") :- filter_run(a, b, _), a != b. // rung, `library callback`: a library's behaviour joined by a member name, never a resolved call. .decl libcb(a:symbol, b:symbol, callee:symbol, member:symbol) .input libcb fw_edge(a, b, "library callback") :- libcb(a, b, _, _), a != b. +// and the member's own answer names who reaches it that way, worded as a framework hop +direct(q, c, "uses", cat("framework-mediated, not a call: a library it calls hands this back (", cat(callee, cat(" calls ", cat(member, ")")))), "framework", "", 0) + :- target(q, "method", m, _), libcb(c, m, callee, member), c != m. // and the filter's own answer names who runs it, worded as a framework hop (the `framework` rule above) direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", cat(how, " [registered]")), "framework", "", 0) :- target(q, "method", m, _), filter_run(c, m, how), c != m. diff --git a/tests/cases/python/library-calls-back/case.json b/tests/cases/python/library-calls-back/case.json index 3019fe7e7..91f0c6af3 100644 --- a/tests/cases/python/library-calls-back/case.json +++ b/tests/cases/python/library-calls-back/case.json @@ -116,6 +116,17 @@ "avoid": [ "tests/test_render.py" ] + }, + { + "why": "path walks the library's hand-back too: from the test to the application the test client calls", + "run": [ + "path", + "test_index", + "App.dispatch" + ], + "want": [ + "library_callback via webkit.testing.Client.get -> __call__" + ] } ] } From 48841c5b430ba40b9c64621d9f39479d8f508ac7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:17:17 -0700 Subject: [PATCH 40/40] fix(python): the dispatch cap is off by default for Python; re-freeze the f-string golden A Python call site with more candidate targets than the cap was refused outright, so every test reached only through it was dropped from a selection. For test selection a missed test costs more than an extra one, so Python solves uncapped unless --dispatch-cap is given; every other language keeps 20. The protocol-fan check tests the cap mechanism itself and now pins it explicitly. The parser's Python golden records an f-string's leading text (literalValue), as the getattr dispatch change made it do; only that column moved, on 14 rows. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/pipeline/run-souffle.sh | 5 +++- graph/test/python/tools/protocol-fan-test.sh | 2 +- .../verified/_golden/expressions.golden | 28 +++++++++---------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 6b9075707..0347b7a5a 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -34,7 +34,7 @@ JDK_DEPTH=1 # max JDK-hop depth engine-ii expands. FORCED (always applied). De # hop 1; deeper JDK expansion only traces internal plumbing (the explosion source). LIB_DEPTH="" # max external-lib-hop depth. OPTIONAL — empty = UNCAPPED (dive deep into the client's # real deps, no sink catalog there). Set with --lib-depth to bound a runaway library. -DISPATCH_CAP="${DISPATCH_CAP:-20}" # fan-width cap on virtual dispatch. DEFAULT 20 (measured): +DISPATCH_CAP="${DISPATCH_CAP:-}" # fan-width cap on virtual dispatch. DEFAULT 20, off for Python (below) (measured): # on cassandra 6.0-alpha2 it lifts change-impact precision d2 0.782->0.920 and # d3 0.569->0.753 at ZERO recall cost against the must-have bytecode set (recall # 1.000/0.996/0.988 unchanged), and halves fabricated false positives. Cap 10 is @@ -81,6 +81,9 @@ PKG="$(cd "$SRC/.." && pwd)" # the package root: package.json, node_modules, p . "$SRC/pipeline/compile-lock.sh" # Rules are PER-LANGUAGE and live under graph//; the executor itself is shared. LANG_ARG="${LANG_ARG:-java}" +# Python's cap is off by default: a Python site over the cap is dropped outright, and for test selection a missed +# test costs more than an extra one. Every other language keeps 20. An explicit --dispatch-cap still wins. +if [ -z "$DISPATCH_CAP" ]; then [ "$LANG_ARG" = python ] && DISPATCH_CAP=off || DISPATCH_CAP=20; fi ENG="$SRC/$LANG_ARG/engine"; ENG2="$SRC/$LANG_ARG/engine-ii"; DL="$SRC/$LANG_ARG/souffle"; TPL="$SRC/$LANG_ARG/templates" [ -d "$ENG" ] || { echo "no rule set for --language=$LANG_ARG (looked in $ENG)" >&2; exit 1; } # shellcheck source=souffle-include.sh diff --git a/graph/test/python/tools/protocol-fan-test.sh b/graph/test/python/tools/protocol-fan-test.sh index 009607d67..b6c9b283d 100755 --- a/graph/test/python/tools/protocol-fan-test.sh +++ b/graph/test/python/tools/protocol-fan-test.sh @@ -64,7 +64,7 @@ PYEOF node "$PARSER" "$W/src" protofan false "$W/ir" > "$W/parse.log" 2>&1 || { echo " FAIL protocol-fan: the parser failed on the fixture"; sed 's/^/ /' "$W/parse.log" | tail -3 echo "protocol-fan: FAILED (1 check)"; exit 1; } -bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python --client-ir "$W/ir" \ +bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python --dispatch-cap 20 --client-ir "$W/ir" \ --library "$EMPTY_LIB" --intermediate "$W/int" --output "$W/out" > "$W/solve.log" 2>&1 || { echo " FAIL protocol-fan: the solve failed"; tail -3 "$W/solve.log" | sed 's/^/ /' echo "protocol-fan: FAILED (1 check)"; exit 1; } diff --git a/parser/src/test-data/python/verified/_golden/expressions.golden b/parser/src/test-data/python/verified/_golden/expressions.golden index a85b5b128..aafc3a063 100644 --- a/parser/src/test-data/python/verified/_golden/expressions.golden +++ b/parser/src/test-data/python/verified/_golden/expressions.golden @@ -457,25 +457,25 @@ kind=DICT_COMPREHENSION edgeRole=ELEMENT rootContext=RETURN_VALUE expressionOwne kind=DOUBLE_STARRED edgeRole=ELEMENT rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_31ce0182d956008cd7c0d4310befbbdd parentExpressionHash=PY_EXPRESSION_5fe14c0ce4b34e49610679f97aaf725c position=0 depth=2 literalType= literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=45 startColumn=24 endLine=45 endColumn=34 pyScopeLinkHash=PY_SCOPE_0afbdbe12de016a0995c6c7a13aaca37 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_45f624e465ab51faddfe90c228b3e988 kind=ELLIPSIS edgeRole=ELEMENT rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_001911db800188746612867847e4e44c parentExpressionHash=PY_EXPRESSION_80717a723b9c3fc36ced88f59b94817c position=7 depth=1 literalType= literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=13 startColumn=47 endLine=13 endColumn=50 pyScopeLinkHash=PY_SCOPE_31e6b8e9224978dec9f7183dc78d3a9d pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_ba89c44823369c6ed7f5fdfa9e71f955 kind=ELLIPSIS edgeRole=ROOT rootContext=EXPRESSION_STATEMENT expressionOwnerKind=METHOD pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 expressionOwnerHash=PY_METHOD_6806aa3abcc5f505e44d6f16f8fb80ac parentExpressionHash= position=0 depth=0 literalType= literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=31 startColumn=36 endLine=31 endColumn=39 pyScopeLinkHash=PY_SCOPE_c901fa8da3eb2e2ad7f339f6bdc770bf pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_fe6931b96e6769b267817eaf7ee07747 -kind=FSTRING edgeRole=ARGUMENT rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=23 endLine=24 endColumn=54 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_142a1b10b805281ccf17b73a1b86930f +kind=FSTRING edgeRole=ARGUMENT rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue=total went negative: comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=23 endLine=24 endColumn=54 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_a5010753f3893f8bda1b1a6af8faaa6d kind=FSTRING edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_41d195c97de3067e9e50a4dfecfdc43d position=0 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=40 startColumn=17 endLine=40 endColumn=31 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_ffe65de3e9e5a1c6b7f2ceec9aac22c9 kind=FSTRING edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_57edf1616ea9720014ce6a4dc86ed854 position=0 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=37 startColumn=18 endLine=37 endColumn=37 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_c1b59c02a86f0b78401a7c1307591a0b -kind=FSTRING edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_ce9c7e66b1e077158fc711b00ee09755 position=0 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=21 endLine=32 endColumn=52 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_0e0e8f6ff22fe02e2db36d8f4993b658 +kind=FSTRING edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_ce9c7e66b1e077158fc711b00ee09755 position=0 depth=1 literalType=FSTRING literalValue={{not-a-field}} then comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=21 endLine=32 endColumn=52 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_7f5638c76b2c0a2369377e3ae1be887c kind=FSTRING edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_f648ccac78998098bdd3a75043806cef position=0 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=36 startColumn=16 endLine=36 endColumn=37 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_95478f7f7a8e01663146793b90c739cc kind=FSTRING edgeRole=RETURN_VALUE rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=11 endLine=40 endColumn=28 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_9625e22c72848632294a94dc9bc99e61 -kind=FSTRING edgeRole=RETURN_VALUE rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=19 endLine=21 endColumn=35 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_c3b46fa37b2ce588f49c331f11228aad -kind=FSTRING edgeRole=RETURN_VALUE rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=19 endLine=24 endColumn=43 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_0b88cd6bb353eaf06c4e3a16d510d783 -kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_142a1b10b805281ccf17b73a1b86930f position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=46 endLine=24 endColumn=53 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_8cedca0c1eae78fa2b22901c93ad04a5 -kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_0e0e8f6ff22fe02e2db36d8f4993b658 position=2 depth=2 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=44 endLine=32 endColumn=51 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_497427cbde6c8aa087ce46f9f5b92251 +kind=FSTRING edgeRole=RETURN_VALUE rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue=X-axis at comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=19 endLine=24 endColumn=43 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_437b9ec4c0cc608227916a4cf80ea7cc +kind=FSTRING edgeRole=RETURN_VALUE rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash= position=0 depth=0 literalType=FSTRING literalValue=Y-axis at comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=19 endLine=21 endColumn=35 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=str inferredTypeKind=BUILTIN_SCALAR inferenceEvidence=FSTRING inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_7972f85af498b4e3e5865bc46c34cf1f +kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_a5010753f3893f8bda1b1a6af8faaa6d position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=46 endLine=24 endColumn=53 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_03c9bbe6b9e5bda6bff79b50456e94c9 +kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_7f5638c76b2c0a2369377e3ae1be887c position=2 depth=2 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=44 endLine=32 endColumn=51 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_18dbb50a7df96d0f65f5c6627a6b7657 kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_95478f7f7a8e01663146793b90c739cc position=1 depth=2 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=36 startColumn=18 endLine=36 endColumn=36 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_b878a19ff3b2e477ac6c7989e2e7cbdf kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_c1b59c02a86f0b78401a7c1307591a0b position=1 depth=2 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=37 startColumn=20 endLine=37 endColumn=36 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_70f31902694075419290696e18bf01dc kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_ffe65de3e9e5a1c6b7f2ceec9aac22c9 position=1 depth=2 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=40 startColumn=19 endLine=40 endColumn=30 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_84c65a4da8f5f3869094cd3e00da2d4c kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_9625e22c72848632294a94dc9bc99e61 position=1 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=13 endLine=40 endColumn=16 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_2b137cc2b7bbf847f4432e49076871fb kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_9625e22c72848632294a94dc9bc99e61 position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=16 endLine=40 endColumn=21 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_c0ad15a619c27b57591162587136f63c kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_9625e22c72848632294a94dc9bc99e61 position=3 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=21 endLine=40 endColumn=27 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_fa20555f28c48a849596f6ef91c9a570 -kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_0b88cd6bb353eaf06c4e3a16d510d783 position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=31 endLine=24 endColumn=34 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_7ca0721b4f4c442c8e91f582c928202b -kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_0b88cd6bb353eaf06c4e3a16d510d783 position=4 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=39 endLine=24 endColumn=42 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_5fcf5f4a03b65bd3dc4915e3ce2c4331 -kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_c3b46fa37b2ce588f49c331f11228aad position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=31 endLine=21 endColumn=34 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_b9a0ca60389cc2945835580f1da0113d +kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_437b9ec4c0cc608227916a4cf80ea7cc position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=31 endLine=24 endColumn=34 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_5fe0b10791ec1439c933c1f756275739 +kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_437b9ec4c0cc608227916a4cf80ea7cc position=4 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=39 endLine=24 endColumn=42 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_c006c884c0ebb9f5a6c132fb45277b95 +kind=FSTRING_INTERPOLATION edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_7972f85af498b4e3e5865bc46c34cf1f position=2 depth=1 literalType=FSTRING literalValue= comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=31 endLine=21 endColumn=34 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_17b0fe069c86717a9a4272ffd93c3840 kind=GENERATOR_EXPRESSION edgeRole=ARGUMENT rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash=PY_TYPE_2cf22ea62c0a0c4b7621a04b81853658 expressionOwnerHash=PY_METHOD_eb79fed25f65d819e35efd7b47447c17 parentExpressionHash=PY_EXPRESSION_09ba19c9032fe53466c4b7012e01a401 position=0 depth=1 literalType= literalValue= comprehensionKind=GENERATOR unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash=PY_SCOPE_67bbfe7bbd0548c8cb0f8c087a45be74 potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=23 startColumn=18 endLine=23 endColumn=47 pyScopeLinkHash=PY_SCOPE_052401055c41676384a9c1bea0471f4e pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=Generator inferredTypeKind=CALLABLE inferenceEvidence=COMPREHENSION inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_b8e2f5af64dc13708910a40f7c4cacf9 kind=GENERATOR_EXPRESSION edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_189c09e79ab38ca2c0dc7828a2d6f5bf parentExpressionHash=PY_EXPRESSION_ad3d3e8874dafbe2667765feb916e1f8 position=0 depth=1 literalType= literalValue= comprehensionKind=GENERATOR unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash=PY_SCOPE_294906b161b7d6e6a3a55130b2cc9f71 potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=36 startColumn=9 endLine=36 endColumn=27 pyScopeLinkHash=PY_SCOPE_5e45bd8fc8fa26d39d5e0ae4bac74752 pyModuleLinkHash=PY_MODULE_f56326f63a4c5bef9294b28a4acb9abc bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=Generator inferredTypeKind=CALLABLE inferenceEvidence=COMPREHENSION inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_3442634e7d8a230a804bd48345e4ac0e kind=GENERATOR_EXPRESSION edgeRole=ASSIGNMENT_VALUE rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_7ba5974f813da22186ba5452d6f6da31 parentExpressionHash=PY_EXPRESSION_c63885d5233e0e473b0b98442173c6b1 position=0 depth=1 literalType= literalValue= comprehensionKind=GENERATOR unaryFixity=NONE operatorString= referencedEntityKind=UNKNOWN referencedEntityHash= lambdaScopeHash=PY_SCOPE_ccaed26f1f6900b224fe927b936dfdc1 potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=21 startColumn=10 endLine=21 endColumn=26 pyScopeLinkHash=PY_SCOPE_fcfac6bebf098704cc3ed334b39583fc pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName=Generator inferredTypeKind=CALLABLE inferenceEvidence=COMPREHENSION inferenceConfidence=CERTAIN serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_14055a485f6749c5e087a41e8b09b58c @@ -1373,8 +1373,8 @@ kind=NAME_REFERENCE edgeRole=EXCEPT_TARGET rootContext=EXCEPT_TYPE expressionOwn kind=NAME_REFERENCE edgeRole=EXCEPT_TARGET rootContext=EXCEPT_TYPE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f42bbeefdd130f31e666473d01c5c56a parentExpressionHash= position=0 depth=0 literalType= literalValue=exc comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=EXCEPT_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=33 startColumn=36 endLine=33 endColumn=39 pyScopeLinkHash=PY_SCOPE_1ae0df05ae39a545a2fc3cc1f22e9edb pyModuleLinkHash=PY_MODULE_4b037be4bfee21f42c86ed8be4545f2b bindingLinkHash=PY_BINDING_93af6d59b48033238ba34b82f5ad8fc7 nameContext=STORE isWrite=true argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_088145d9723954c6210bd3086132842a kind=NAME_REFERENCE edgeRole=EXCEPT_TYPE rootContext=EXCEPT_TYPE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f42bbeefdd130f31e666473d01c5c56a parentExpressionHash= position=0 depth=0 literalType= literalValue=ValueError comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=BUILTIN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=31 startColumn=11 endLine=31 endColumn=21 pyScopeLinkHash=PY_SCOPE_1ae0df05ae39a545a2fc3cc1f22e9edb pyModuleLinkHash=PY_MODULE_4b037be4bfee21f42c86ed8be4545f2b bindingLinkHash=PY_BINDING_96283132bcc78fa9d76c2c8f0075254a nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_2d757c9eb705e0858555789af6b741ff kind=NAME_REFERENCE edgeRole=EXCEPT_TYPE rootContext=EXCEPT_TYPE expressionOwnerKind=METHOD pyTypeLinkHash=PY_TYPE_cb9da9964bc3c43761a4e1269506effe expressionOwnerHash=PY_METHOD_c0d08eb80ac0506fff63cb9be9f81d18 parentExpressionHash= position=0 depth=0 literalType= literalValue=NameError comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=BUILTIN referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=50 startColumn=15 endLine=50 endColumn=24 pyScopeLinkHash=PY_SCOPE_21dd86600fc580014a7dbb60290ab8cb pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_7e6b07f7eb1ea2be0a40904a66735ed1 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_5e89becd05e88e1636a63c399745a03d -kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_8cedca0c1eae78fa2b22901c93ad04a5 position=0 depth=2 literalType= literalValue=total comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=47 endLine=24 endColumn=52 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_dc63ccf9417308212a793c0929d26e21 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_8d58c66a70a75ccb78d64e02f7e60fd7 -kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_497427cbde6c8aa087ce46f9f5b92251 position=0 depth=3 literalType= literalValue=value comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=45 endLine=32 endColumn=50 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_0cdad00799d078976f035713c93e3882 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_2b48897d329f807ddbc6aa54050124cb +kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSERT_MESSAGE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_03c9bbe6b9e5bda6bff79b50456e94c9 position=0 depth=2 literalType= literalValue=total comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=24 startColumn=47 endLine=24 endColumn=52 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_dc63ccf9417308212a793c0929d26e21 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_bfe18d9455fc2e789152efa992ce4be3 +kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_18dbb50a7df96d0f65f5c6627a6b7657 position=0 depth=3 literalType= literalValue=value comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=32 startColumn=45 endLine=32 endColumn=50 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_0cdad00799d078976f035713c93e3882 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_484e02d7ed6ae7c7a47cefe19e90aa41 kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_70f31902694075419290696e18bf01dc position=0 depth=3 literalType= literalValue=value comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=37 startColumn=21 endLine=37 endColumn=26 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_0cdad00799d078976f035713c93e3882 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_ef2a2b91000f072ce0a5954447b1ffb1 kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_70f31902694075419290696e18bf01dc position=1 depth=3 literalType= literalValue=width comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=37 startColumn=29 endLine=37 endColumn=34 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_3aedc557cafab829a1a94eaa0ed9f865 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_efef10f40aacf8eb98764fd7030115a8 kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_d4b52d2b10c5e9f0a53000438c219a54 parentExpressionHash=PY_EXPRESSION_84c65a4da8f5f3869094cd3e00da2d4c position=0 depth=3 literalType= literalValue=value comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=40 startColumn=20 endLine=40 endColumn=25 pyScopeLinkHash=PY_SCOPE_ae7be4922d0aedcd09a1419a77c8f8d2 pyModuleLinkHash=PY_MODULE_99797da9f6b8580a03c8a2f7bf6898ea bindingLinkHash=PY_BINDING_0cdad00799d078976f035713c93e3882 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_197fe2c453b1416b8057d63a445424fe @@ -1383,9 +1383,9 @@ kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=ASSIGNMENT_VALUE exp kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_2b137cc2b7bbf847f4432e49076871fb position=0 depth=2 literalType= literalValue=a comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=14 endLine=40 endColumn=15 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash=PY_BINDING_1a4851b63ccd8953e6b2211d1ddf02f2 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_4cca6de327cb4df693358204ce7a4c0b kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_c0ad15a619c27b57591162587136f63c position=0 depth=2 literalType= literalValue=a comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=17 endLine=40 endColumn=18 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash=PY_BINDING_1a4851b63ccd8953e6b2211d1ddf02f2 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_9af65ea3a92eaad5f6a9094d5f45a79a kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_ec9048b215bdcfbb479d8dbdaa05f2dd parentExpressionHash=PY_EXPRESSION_fa20555f28c48a849596f6ef91c9a570 position=0 depth=2 literalType= literalValue=a comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=PARAMETER referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=40 startColumn=22 endLine=40 endColumn=23 pyScopeLinkHash=PY_SCOPE_4df288bff57e46fe19bd20cd70987f14 pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash=PY_BINDING_1a4851b63ccd8953e6b2211d1ddf02f2 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_9775ca061c06f9aead9c81a7fe10ecea -kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_5fcf5f4a03b65bd3dc4915e3ce2c4331 position=0 depth=2 literalType= literalValue=p comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=40 endLine=24 endColumn=41 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_cdc864b1aaf0f3c4194100f238ef7d2d nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_90a097bdd17d3b4a691c46bbb5ee91bd -kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_7ca0721b4f4c442c8e91f582c928202b position=0 depth=2 literalType= literalValue=x comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=32 endLine=24 endColumn=33 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_3090834cf513152f94ba99f4bb8bbbcb nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_4a1eba417775ab9b985d12b36f02023c -kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_b9a0ca60389cc2945835580f1da0113d position=0 depth=2 literalType= literalValue=y comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=32 endLine=21 endColumn=33 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_fe26ad5d710c24afe0ddb7e8084fd75a nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_a963e6e30e2036a900c6f791cbb2dd9d +kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_17b0fe069c86717a9a4272ffd93c3840 position=0 depth=2 literalType= literalValue=y comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=1 startLine=21 startColumn=32 endLine=21 endColumn=33 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_fe26ad5d710c24afe0ddb7e8084fd75a nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_2cf5cc2083c9b2adf9001e277d735cec +kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_5fe0b10791ec1439c933c1f756275739 position=0 depth=2 literalType= literalValue=x comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=32 endLine=24 endColumn=33 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_3090834cf513152f94ba99f4bb8bbbcb nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_46784c6dc595ec876676e125ca71c445 +kind=NAME_REFERENCE edgeRole=FSTRING_EXPRESSION rootContext=RETURN_VALUE expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_f1cabc90b966fb3a98cd89e50251d1d1 parentExpressionHash=PY_EXPRESSION_c006c884c0ebb9f5a6c132fb45277b95 position=0 depth=2 literalType= literalValue=p comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=2 startLine=24 startColumn=40 endLine=24 endColumn=41 pyScopeLinkHash=PY_SCOPE_6bda6e1434a5cb0909c09a57165f2051 pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash=PY_BINDING_cdc864b1aaf0f3c4194100f238ef7d2d nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_96b732aa348f45096618e600da92efbf kind=NAME_REFERENCE edgeRole=LAMBDA_BODY rootContext=LAMBDA_BODY expressionOwnerKind=LAMBDA pyTypeLinkHash= expressionOwnerHash=PY_METHOD_6ebf5906dc1a40c5298055722ffd0c1b parentExpressionHash=PY_EXPRESSION_eb9d6cd1cb6f9ef8ef2c8b55b06a4883 position=0 depth=2 literalType= literalValue=tally comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=FREE_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex=0 startLine=75 startColumn=31 endLine=75 endColumn=36 pyScopeLinkHash=PY_SCOPE_ece5bc24f8aeb3ecb3b58b0834e6071a pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_63a7ff8f45a05b4ceb655803d16c07e6 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_42867c72970ef3af0683e99547119ab1 kind=NAME_REFERENCE edgeRole=MATCH_PATTERN rootContext=CASE_PATTERN expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_6ae892061c29f24a057a14fd8f40caf5 parentExpressionHash= position=0 depth=0 literalType= literalValue=Guard comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=TYPE referencedEntityHash=PY_TYPE_1c6b35d525295d3a2c4cffc78afc2d19 lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=56 startColumn=13 endLine=56 endColumn=18 pyScopeLinkHash=PY_SCOPE_3a06be9b75ee27fd96e09376ba576f85 pyModuleLinkHash=PY_MODULE_4b037be4bfee21f42c86ed8be4545f2b bindingLinkHash=PY_BINDING_b61489f9f06fe5894f32ff91707c8151 nameContext=LOAD isWrite=false argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_48534e56f6cab086d90847873f3e89ad kind=NAME_REFERENCE edgeRole=MATCH_PATTERN rootContext=CASE_PATTERN expressionOwnerKind=METHOD pyTypeLinkHash= expressionOwnerHash=PY_METHOD_6ae892061c29f24a057a14fd8f40caf5 parentExpressionHash= position=0 depth=0 literalType= literalValue=v comprehensionKind=NONE unaryFixity=NONE operatorString= referencedEntityKind=LOCAL_VARIABLE referencedEntityHash= lambdaScopeHash= potentialQualifiedName= isAmbiguous=false returnStatementIndex= startLine=54 startColumn=19 endLine=54 endColumn=20 pyScopeLinkHash=PY_SCOPE_3a06be9b75ee27fd96e09376ba576f85 pyModuleLinkHash=PY_MODULE_4b037be4bfee21f42c86ed8be4545f2b bindingLinkHash=PY_BINDING_1ed938532fa655ef3ed395889eb34f40 nameContext=STORE isWrite=true argumentKeywordName= isAwaited=false isStarred=false dottedPath= inferredTypeName= inferredTypeKind=UNKNOWN inferenceEvidence=NONE inferenceConfidence=NONE serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyExpressionUniqueHash=PY_EXPRESSION_fc3d07df7b6749c55a6a40e8051055b9