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/27] 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 8a9a7700..1215ed2b 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 332f0630..15c58497 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 40aed3c8..92c54f4d 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 56518799..890a08ad 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 75e9d129..6007a400 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 00000000..e5c8fd2d --- /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 00000000..46ad4ace --- /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 00000000..f75ffe5b --- /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/27] 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 6007a400..284c6e88 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 005be814..0b2b39af 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 00000000..bc38818e --- /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 00000000..81605ed7 --- /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 00000000..6144da95 --- /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 00000000..a5fdc992 --- /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/27] 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 f90275c3..b4b87c8f 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 0b2b39af..500217c8 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 f5283b76..533319b3 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 f8d70e36..7910a4f8 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 00000000..1f010be6 --- /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 00000000..0796c624 --- /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 00000000..0584ce98 --- /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 00000000..0df97955 --- /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 00000000..1ef5aeb3 --- /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/27] 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 50bd20c4..d07fcb1c 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 00000000..2257c3b4 --- /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 00000000..5343d15f --- /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 00000000..a3746a06 --- /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 00000000..c2f69520 --- /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 00000000..a7895a60 --- /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 00000000..e4e12860 --- /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 00000000..68e7e66a --- /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 00000000..572becf2 --- /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 00000000..29eecc05 --- /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 00000000..d204209c --- /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 00000000..77f0280a --- /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 00000000..b647e452 --- /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/27] 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 c9f675e1..99b05b5a 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 2c8e5ef2..0d3cff9f 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 00000000..76b81bfb --- /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 00000000..6e377d46 --- /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 00000000..4221175c --- /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 00000000..bfebedc5 --- /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/27] 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 af6bdd93..ad69acdb 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 2c79e52b..e78863e2 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 b7b56647..e30d8c1f 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 00000000..cd47315d --- /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 00000000..cbf366d7 --- /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 00000000..a5864f2e --- /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 00000000..8b97967e --- /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 00000000..e1d9291d --- /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 00000000..8424af35 --- /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 00000000..94936ac2 --- /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 00000000..2086d532 --- /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 00000000..21df4a2e --- /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 00000000..bee2917a --- /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 00000000..8b109c46 --- /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 00000000..d64224b8 --- /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 00000000..9cffc0a6 --- /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 00000000..f4478092 --- /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 00000000..4234f11f --- /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/27] 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 39dbe789..5745b29d 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 e30d8c1f..bc489cb6 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 c3d46e25..46a76a11 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 d56c695d..8373c4e3 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 00000000..dcb66027 --- /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 00000000..0ca257ee --- /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 00000000..f1fd17f2 --- /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 00000000..2fda0a4c --- /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 00000000..512a36e3 --- /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 00000000..64b59b8b --- /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 00000000..942919bf --- /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 00000000..fd06b995 --- /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 00000000..0e1cc8ec --- /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 00000000..7333efb6 --- /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 00000000..2151714c --- /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 00000000..39b600b4 --- /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 00000000..4b219615 --- /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 00000000..3f40d043 --- /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 00000000..2844d30b --- /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 00000000..5ae0a6cb --- /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 00000000..6332264f --- /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 00000000..f69644bc --- /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 00000000..bfed47cf --- /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 00000000..b7b24386 --- /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 00000000..bf228d69 --- /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 00000000..19c2ac86 --- /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 00000000..f38f30c4 --- /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 00000000..1898ad42 --- /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 00000000..820234a4 --- /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 00000000..53e4e1a6 --- /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 00000000..873712ec --- /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 00000000..f7dfa907 --- /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 00000000..85733214 --- /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 00000000..5a4a9bf6 --- /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 00000000..82c77371 --- /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 00000000..9ef95bfc --- /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 00000000..c82a1032 --- /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/27] 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 0d5c5485..71d448e5 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 0c07497a..0ec56d2b 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 00000000..97a74a7f --- /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 00000000..66c63213 --- /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 00000000..475cf4ea --- /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 00000000..79628415 --- /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 00000000..3f70ad89 --- /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 00000000..c311e64b --- /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 00000000..e69de29b 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 00000000..c459b14b --- /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/27] 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 0ede5dbf..c2c8dc38 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 14ef74b5..69fb4ae4 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 00000000..e69de29b 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 00000000..687b3245 --- /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 00000000..828a6f45 --- /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 00000000..12d7146d --- /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 00000000..54302900 --- /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 00000000..54cfaa2e --- /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/27] 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 b1c334c5..c6bf5e07 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 ca1e14d1..02da0163 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 d6b069c6..a1ab9506 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 00000000..58328af4 --- /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 00000000..e69de29b 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 00000000..08c14f15 --- /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/27] 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 7a8dc195..df2fcce2 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 f8d70e36..69752bec 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 00000000..f50525ba --- /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 00000000..e69de29b 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 00000000..1bb817e7 --- /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 00000000..1a974c9c --- /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 00000000..5e8f6973 --- /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/27] 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 df2fcce2..d840e495 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 f50525ba..4d1ef066 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 1bb817e7..96beb1b6 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/27] 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 69752bec..e831a6a7 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 00000000..8ca71978 --- /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 00000000..e69de29b 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 00000000..c37360f8 --- /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 00000000..c0b95782 --- /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 00000000..17baa6db --- /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/27] 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 2c17b391..a78cdc69 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 2e9af322..ac5e5a3c 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 c6bf5e07..6c35b6cf 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 3c032d13..7b44fe2d 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 a1ab9506..4818adea 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 107989af..3a454e7e 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 06b88e32..daf7e130 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 4d85cc1a..557d3208 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 95fd8d90..4267008a 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 f5283b76..a1284328 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 00000000..ef9c8fbb --- /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 00000000..e69de29b 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 00000000..49ecf51e --- /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 00000000..fd8cf303 --- /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 00000000..843cb79f --- /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 00000000..b88149b7 --- /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/27] 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 d46b6eca..ed530619 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 0d671aa9..2b9087a4 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 4818adea..6f1fd864 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 00000000..5747f72b --- /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 00000000..e69de29b 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 00000000..71acab0a --- /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/27] 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 d840e495..75338e6d 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 00000000..2efeb4d3 --- /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 00000000..6d1bd6e7 --- /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 00000000..3df798e6 --- /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 00000000..43ce0e0a --- /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 00000000..aa144041 --- /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 00000000..539bd9a1 --- /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 00000000..2733328f --- /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/27] 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 e584db41..8ac8b94d 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 fb90ddc4..1c76e1cb 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 b1faa46b..2e454837 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 00000000..d5cdce43 --- /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 00000000..bc827cc6 --- /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 00000000..a1921629 --- /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 00000000..d6694f89 --- /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 00000000..667bd0a5 --- /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 00000000..c6a5aed1 --- /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 00000000..d3b2f030 --- /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/27] 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 51c36cbd..bb67ac13 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 00000000..aeb09220 --- /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 00000000..dcc37da0 --- /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 00000000..d3b2f030 --- /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 00000000..63c0481c --- /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 00000000..3c5d7f26 --- /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/27] 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 27be6bf5..841c1f86 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 00000000..759af318 --- /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 00000000..3f0e91e3 --- /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 00000000..d3b2f030 --- /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/27] 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 2c17b391..75b60d9b 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 2e9af322..b687c274 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 69647979..c0e3de06 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 2e454837..778520ef 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 f5283b76..4e1a9965 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 00000000..0b0cde40 --- /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 00000000..18c78d86 --- /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 00000000..d3b2f030 --- /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 00000000..fc56a879 --- /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/27] 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 23516414..5a09820c 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 00000000..e9a105ee --- /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 00000000..6240b341 --- /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 00000000..581198a3 --- /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 00000000..83b04b43 --- /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/27] 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 5c962379..fe5e35e7 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 00000000..4170ca10 --- /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 00000000..b1c00ba0 --- /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 00000000..d3b2f030 --- /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 00000000..570d5ae9 --- /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/27] 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 46e28c76..8a82e1ba 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 f2ec9440..e7bc391f 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 1c76e1cb..f4d1b803 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 778520ef..89a6ff36 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 d0fd6d4d..b7abc63c 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 00000000..acc2d4e0 --- /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 00000000..5bf51e29 --- /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 00000000..d3b2f030 --- /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 00000000..a0229463 --- /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/27] 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 4a625ae2..89ca7349 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 7a8dc195..c007571d 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 f8d70e36..7ca44f79 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 ecf87c75..1e7d53e7 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 00000000..211f16bc --- /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 00000000..1a3d00c4 --- /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 00000000..2909d424 --- /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 00000000..6ec2d58a --- /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 00000000..aab102ad --- /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 00000000..bf4490b5 --- /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 00000000..2dc78506 --- /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 00000000..f9b7cf8d --- /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 00000000..0626ac63 --- /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 00000000..540b874a --- /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 00000000..ff04aee9 --- /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 00000000..61093871 --- /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 00000000..2ef24753 --- /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/27] 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 32dd5af9..f312f5d0 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 90254d5e..53ac9021 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 36c85d54..a3a0543d 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 d30a7754..f7a61394 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 00000000..a2c6c6b8 --- /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 00000000..129a14b7 --- /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 00000000..cc5baf28 --- /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 00000000..71e7e0d6 --- /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 00000000..a9b06d23 --- /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 00000000..914a7e27 --- /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/27] 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 c007571d..645e2e0f 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 211f16bc..86364985 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 00000000..04a1e7b2 --- /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 00000000..c472490d --- /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 00000000..2e94a757 --- /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/27] 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 7ca44f79..be879f51 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 1e7d53e7..34521239 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 00000000..f4cf094f --- /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 00000000..c034c91a --- /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 00000000..6a92c434 --- /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 00000000..9f726fd4 --- /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 00000000..5e328593 --- /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 00000000..0d2a3796 --- /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 00000000..ac5cfbd6 --- /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 00000000..becee093 --- /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 00000000..28864b46 --- /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 00000000..de93e179 --- /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"; } +}