From cbf73f9b746736b98fb2cc0d97a24f336347f146 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:22:15 -0700 Subject: [PATCH 01/37] release 0.1.6: every package and plugin manifest at 0.1.6 (the parser keeps its own version) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- gemini-extension.json | 2 +- package.json | 12 ++++++------ plugins/axiomcode/.claude-plugin/plugin.json | 2 +- plugins/axiomcode/.codex-plugin/plugin.json | 2 +- plugins/axiomcode/.cursor-plugin/plugin.json | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gemini-extension.json b/gemini-extension.json index e8f293b1a..2c6ca0db2 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.5", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/package.json b/package.json index 6d6335c85..db70f7a16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.5", + "version": "0.1.6", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.5", - "@axiomcode/engine-darwin-x64": "0.1.5", - "@axiomcode/engine-linux-x64": "0.1.5", - "@axiomcode/engine-linux-arm64": "0.1.5", - "@axiomcode/engine-win32-x64": "0.1.5" + "@axiomcode/engine-darwin-arm64": "0.1.6", + "@axiomcode/engine-darwin-x64": "0.1.6", + "@axiomcode/engine-linux-x64": "0.1.6", + "@axiomcode/engine-linux-arm64": "0.1.6", + "@axiomcode/engine-win32-x64": "0.1.6" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index d2d495463..28e1ce92e 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.5", + "version": "0.1.6", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 35878e94b..d13a75434 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.5", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 538a6f939..32feb6586 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.5", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", From 0e7fd22080bbc6040d3dbdcb834444a750b9d5c7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:54:55 -0700 Subject: [PATCH 02/37] path / impact: a file:line whose partial path matches two files names both and asks which (#1599) A partial path (a basename, or dir/file) took the smallest span across every file it matched, so the answer was for whichever function at that line was shorter, labelled with what was typed. The file is now settled first: the path written in full, else the one file it is a suffix of; two or more are refused with each file and what spans the line there. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/axiomcode-path | 19 +++++++++- .../fileline-partial-path-two-files/case.json | 38 +++++++++++++++++++ .../lib/services/order.service.js | 4 ++ .../lib/services/userService.js | 6 +++ .../src/controllers/orders.controller.js | 7 ++++ .../src/services/order.service.js | 6 +++ .../src/services/userService.js | 4 ++ 7 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/case.json create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js create mode 100644 tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 4fec0b8f7..8fb7cab13 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -250,9 +250,24 @@ class G: m = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) if m and ('/' in m.group(1) or '.' in m.group(1)): # file:line → the method containing it (f, outside), ln = self.repo_file(m.group(1)), int(m.group(2)) + # A PARTIAL PATH NAMES ONE FILE OR IS REFUSED (#1599). `order.service.js:2` with the file under both src/ and + # lib/ took the smallest span across BOTH files, so the answer was for whichever function at that line was + # shorter, labelled with what was typed. The path is settled first: the file written in full, else the one + # file it is a suffix of; two or more are listed, each with what spans the line there. + files = sorted({x['file'] for x in self.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") + if x['file'] == f or (x['file'] or '').endswith('/' + f)}) + if f in files: files = [f] + if len(files) > 1: + rows = [] + for fx in files[:12]: + at = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY end_line - line LIMIT 1", fx, ln, ln) + rows.append(f" {fx}:{ln} " + (self.disp(at[0]['id']) if at else '(top-level code)')) + if len(files) > 12: rows.append(f" … +{len(files) - 12} more") + die(f"'{m.group(1)}' matches {len(files)} indexed files — give enough of the path to name one:\n" + '\n'.join(rows)) + if files: f = files[0] # the smallest callable(s) spanning the line — several when they share it (`class Item { run(){} tag(){} }`); # top-level code belongs to the file's module node, which is a caller like any other - r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, f"%/{f}", ln, ln) + r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) if r: ids = [x['id'] for x in r if x['span'] == r[0]['span']] # AN ANNOTATION IS NOT THE FUNCTION. `export const f: (a: A) => B = (a) => …` puts a one-line call @@ -263,7 +278,7 @@ class G: start = {self.sym[i].get('line') for i in ids if i in self.sym} ids += [x['id'] for x in r if x['id'] not in ids and self.sym.get(x['id'], {}).get('line') in start and self.method_kind(x['id']) not in BODILESS_KINDS] return (f"{self.disp(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids - r = self.q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND kind = 'module' AND method_id IS NOT NULL LIMIT 1", f, f"%/{f}") + r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL LIMIT 1", f) if r: return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] if outside: die(f"{m.group(1)} is outside the indexed repository {self.repo}: give the file relative to that root, or ask the graph of the repository it belongs to") die(f"no callable spans {s}") diff --git a/tests/cases/javascript/fileline-partial-path-two-files/case.json b/tests/cases/javascript/fileline-partial-path-two-files/case.json new file mode 100644 index 000000000..c3203ebc2 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/case.json @@ -0,0 +1,38 @@ +{"lang": "javascript", + "checks": [ + {"why": "a basename matching two indexed files is refused with both files named, not answered for the one whose function at that line is shorter (#1599)", + "run": ["impact", "order.service.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/order.service.js", "lib/services/order.service.js"]}, + {"why": "the same through path, with a dir/file suffix", + "run": ["path", "*", "services/order.service.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/order.service.js", "lib/services/order.service.js"], + "avoid": ["0 method(s)"]}, + {"why": "the same without a dot in the basename, where the src function is the shorter one", + "run": ["impact", "userService.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/userService.js", "lib/services/userService.js"]}, + {"why": "the same through path", + "run": ["path", "*", "services/userService.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files"]}, + {"why": "control: the full path to the file with callers answers for it, with its caller", + "run": ["impact", "src/services/order.service.js:2"], + "want": ["change: placeOrder (at src/services/order.service.js:2)", "createOrder"]}, + {"why": "control: the full path to the other file answers for that one alone", + "run": ["impact", "lib/services/order.service.js:2"], + "want": ["change: placeOrder (at lib/services/order.service.js:2)"], + "avoid": ["createOrder", "matches 2 indexed files"]}, + {"why": "control: path from everything into the full path finds the caller", + "run": ["path", "*", "src/services/order.service.js:2"], + "want": ["createOrder"], + "avoid": ["matches 2 indexed files"]}, + {"why": "control: a partial path that matches one indexed file still answers", + "run": ["impact", "controllers/orders.controller.js:4"], + "want": ["change: createOrder"], + "avoid": ["matches 2 indexed files"]}, + {"why": "control: a partial path matching no indexed file is still refused as before", + "run": ["impact", "zzz/order.service.js:2"], + "expect_error": true, + "want": ["no callable spans zzz/order.service.js:2"]}]} diff --git a/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js new file mode 100644 index 000000000..5d67a55d2 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js @@ -0,0 +1,4 @@ +function placeOrder() { + return null; +} +module.exports = { placeOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js new file mode 100644 index 000000000..ee701fa1f --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js @@ -0,0 +1,6 @@ +function findUser(id) { + const key = String(id); + const row = { id: key }; + return row; +} +module.exports = { findUser }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js b/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js new file mode 100644 index 000000000..1fb502cc7 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js @@ -0,0 +1,7 @@ +const { placeOrder } = require('../services/order.service'); + +async function createOrder(req, res) { + res.json(await placeOrder(req.params.id)); +} + +module.exports = { createOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js b/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js new file mode 100644 index 000000000..475246937 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js @@ -0,0 +1,6 @@ +const { findUser } = require('./userService'); +async function placeOrder(userId) { + const user = findUser(userId); + return { user }; +} +module.exports = { placeOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js b/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js new file mode 100644 index 000000000..70b9d91b5 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js @@ -0,0 +1,4 @@ +function findUser(id) { + return { id }; +} +module.exports = { findUser }; From 6230975a49ad3e9f77e8ead59c4bc108db9eb240 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:25:47 -0700 Subject: [PATCH 03/37] parser: 0.1.2, for the parser changes on 0.1.4 The vendored parser keeps its own version and moves once per release, only when parser code changed. dev and main ship it at 0.1.1. On 0.1.4, four merged changes touched parser/: #1592 (a concise arrow body is owned by the arrow), #1616 (generated-output detection), #1621 (a package's published API is rooted from package.json) and #1625 (the Java walk and build directories). So 0.1.2, one bump for the release. (cherry picked from commit 51c5e8e151b8ff5af2220f2a7709bce011877822) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser/package.json b/parser/package.json index 6c9395caa..eac08e048 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.1", + "version": "0.1.2", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", From 009f8536ca19bb25fc65505a08c069f092890e05 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:39:03 -0700 Subject: [PATCH 04/37] typescript: a callback handed to a library higher-order function is reached from the site that hands it over, not from every caller of the same signature `xs.map((x) => ...)` stored the arrow in the parameter of a bodiless `map` declaration, so it became a value candidate of one function type shared by every `.map` call: impact through one map callback reached every caller of `.map` in the repository, and a walk down from any caller reached every map callback. Where no project body runs at the call site (a .d.ts member, an ambient declaration, an unresolved callee), the argument no longer flows into the parameter. The site itself gets a `callback_registered` edge to the function it hands over, the tier the JavaScript engine already uses for the same shape. A project higher-order function with a body keeps its dispatch through the parameter. Golden case 78 covers inline, named, local-held and ambient hand-overs, with a project HOF as control. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 4 +- graph/bundle/schema.ts | 4 +- .../src/app.ts | 49 ++++++++++++ .../src/globals.d.ts | 15 ++++ .../src/rate.ts | 1 + .../src/tsconfig.json | 9 +++ .../08-function-values-and-callbacks.edges | 1 + ...08-function-values-and-callbacks.lib.edges | 1 + .../expected/17-constrained-generics.edges | 1 + .../17-constrained-generics.lib.edges | 1 + .../expected/19-overload-gauntlet.edges | 1 + .../expected/19-overload-gauntlet.lib.edges | 1 + .../expected/27-readonly-array-receiver.edges | 1 + .../27-readonly-array-receiver.envelope | 1 - .../28-property-chain-container.edges | 4 + .../28-property-chain-container.envelope | 4 - .../expected/34-callback-of-callback.edges | 4 + .../expected/34-callback-of-callback.envelope | 3 - .../expected/38-dynamic-import.edges | 1 + .../expected/41-library-generic-return.edges | 1 + .../69-named-and-inline-handlers.edges | 3 + .../69-named-and-inline-handlers.envelope | 1 - .../70-function-type-target-dispatch.edges | 3 + .../70-function-type-target-dispatch.envelope | 3 - ...bject-literal-and-expression-callees.edges | 2 + ...ct-literal-and-expression-callees.envelope | 1 - .../expected/74-jsx-component-forms.edges | 4 + .../expected/74-jsx-component-forms.envelope | 4 - .../expected/75-jsx-wrapper-guards.edges | 12 +++ .../expected/75-jsx-wrapper-guards.envelope | 8 -- .../76-jsx-default-nested-wrappers.edges | 6 ++ .../76-jsx-default-nested-wrappers.envelope | 4 - .../78-hof-callback-at-library-boundary.edges | 15 ++++ ...8-hof-callback-at-library-boundary.entries | 10 +++ ...-hof-callback-at-library-boundary.envelope | 2 + ...78-hof-callback-at-library-boundary.fields | 0 ...callback-at-library-boundary.fields-oracle | 7 ++ ...78-hof-callback-at-library-boundary.oracle | 1 + ...-hof-callback-at-library-boundary.type-use | 2 + ...-callback-at-library-boundary.types-oracle | 7 ++ .../test/typescript/tools/normalize_edges.py | 4 + .../engine/call-edge-generation/call_chain.dl | 10 +++ .../engine/resolution/value-flow.dl | 45 ++++++++++- graph/typescript/souffle/decls_all.dl | 3 + .../case.json | 80 +++++++++++++++++++ .../src/app.ts | 49 ++++++++++++ .../src/globals.d.ts | 15 ++++ .../src/rate.ts | 1 + .../src/tsconfig.json | 9 +++ .../typescript/registered-handler/case.json | 2 +- 50 files changed, 385 insertions(+), 35 deletions(-) create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts create mode 100644 graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json delete mode 100644 graph/test/typescript/expected/27-readonly-array-receiver.envelope delete mode 100644 graph/test/typescript/expected/28-property-chain-container.envelope delete mode 100644 graph/test/typescript/expected/69-named-and-inline-handlers.envelope create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use create mode 100644 graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/case.json create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts create mode 100644 tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 7a17d7bf9..7bd4051a8 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -552,7 +552,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `implicit_constructor` | javascript | `new C()` / `super()` where no constructor exists up the chain: the synthesized default runs. A correct end; callee is NULL. | | `dynamic_terminal` | javascript | `obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL. | | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | -| `callback_registered` | javascript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | +| `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | | `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | @@ -574,7 +574,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row. - **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index c4bfee466..8467c9467 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -511,7 +511,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'implicit_constructor', languages: S, meaning: '`new C()` / `super()` where no constructor exists up the chain: the synthesized default runs. A correct end; callee is NULL.' }, { table: 'call_edges', column: 'tier', value: 'dynamic_terminal', languages: S, meaning: '`obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL.' }, { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, - { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: S, meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, + { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, @@ -752,7 +752,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts new file mode 100644 index 000000000..198ebefae --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts @@ -0,0 +1,49 @@ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY. `map`, `forEach` and `onReady` are declarations +// only, so the site that hands the callback over is what reaches it: each function below reaches its own +// callback and nothing another function handed to the same `map`. +import { RATE } from './rate' + +const seen: number[] = [] +function record(n: number): void { + seen[0] = n +} + +export function scaled(xs: number[]): number[] { + return xs.map((x) => x * RATE) +} + +export function doubled(xs: number[]): number[] { + return xs.map((x) => x * 2) +} + +function double(n: number): number { + return n * 2 +} + +// a named function handed over: still reached, from the site that names it +export function named(xs: number[]): number[] { + return xs.map(double) +} + +// …and one held by a local first +export function viaLocal(xs: number[]): void { + const log = (n: number) => record(n) + xs.forEach(log) +} + +// an ambient declaration with no body anywhere: a host API that calls back +declare function onReady(cb: () => void): void + +export function atStartup(): void { + onReady(() => record(RATE)) +} + +// CONTROL: a project higher-order function has a body that calls its parameter, so the callback is +// still a dispatch candidate of the parameter's function type, reached through `each`'s own call. +export function each(xs: number[], fn: (n: number) => void): void { + for (const x of xs) fn(x) +} + +export function useEach(xs: number[]): void { + each(xs, (n) => record(n + RATE)) +} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts new file mode 100644 index 000000000..32f96ef81 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts @@ -0,0 +1,15 @@ +// The slice of the standard library this case hands callbacks to. Declared here, as client code, so the +// client-only pass sees `map` and `forEach` as what they are to the project: signatures with no body. +interface Array { + map(fn: (v: T) => U): U[]; + forEach(fn: (v: T) => void): void; +} +interface Object {} +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts new file mode 100644 index 000000000..b8af0f524 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts @@ -0,0 +1 @@ +export const RATE = 3 diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.edges index c0af49567..c0aeef314 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.edges +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.edges @@ -1,5 +1,6 @@ ambiguous_unknown FUNCTION_CALL values#useLibrary() @L40 -> - ambiguous_unknown METHOD_CALL values#drive() @L29 -> - +callback_registered FUNCTION_CALL values#useLibrary() @L40 -> values#local(?) known_edge CONSTRUCTOR_CALL values#drive() @L27 -> Pipeline#() known_edge FUNCTION_CALL values#apply(Transform,string) @L12 -> values#(string) known_edge FUNCTION_CALL values#drive() @L30 -> values#(string) diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges index 7cd0700d6..20caf154d 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges @@ -2,6 +2,7 @@ ambiguous_unknown METHOD_CALL values#drive() @L29 -> - boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#(string) boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#identity(?) boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#withHook(Hook,string) +callback_registered FUNCTION_CALL values#useLibrary() @L40 -> values#local(?) known_edge CONSTRUCTOR_CALL values#drive() @L27 -> Pipeline#() known_edge FUNCTION_CALL values#apply(Transform,string) @L12 -> values#(string) known_edge FUNCTION_CALL values#drive() @L30 -> values#(string) diff --git a/graph/test/typescript/expected/17-constrained-generics.edges b/graph/test/typescript/expected/17-constrained-generics.edges index 765f00dc8..a2f3599cc 100644 --- a/graph/test/typescript/expected/17-constrained-generics.edges +++ b/graph/test/typescript/expected/17-constrained-generics.edges @@ -7,6 +7,7 @@ ambiguous_unknown FUNCTION_CALL consumer#widestLast() @L35 -> - ambiguous_unknown METHOD_CALL consumer#ownConstraintNamed(T) @L46 -> - ambiguous_unknown METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> - ambiguous_unknown METHOD_CALL curried-local#widest(unknown) @L49 -> - +callback_registered METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> curried-local#()@L33 known_edge FUNCTION_CALL consumer#localDataFirst() @L56 -> curried-local#pluck(readonly T[],'name') known_edge FUNCTION_CALL consumer#localDataLast() @L59 -> curried-local#pluck('name') known_edge FUNCTION_CALL consumer#localKeyed(Row) @L62 -> curried-local#at(T,T) diff --git a/graph/test/typescript/expected/17-constrained-generics.lib.edges b/graph/test/typescript/expected/17-constrained-generics.lib.edges index 6655ffe07..a31de0df3 100644 --- a/graph/test/typescript/expected/17-constrained-generics.lib.edges +++ b/graph/test/typescript/expected/17-constrained-generics.lib.edges @@ -7,6 +7,7 @@ boundary_lib FUNCTION_CALL consumer#keyedFirst(Row) @L23 -> curried#at(T,T) boundary_lib FUNCTION_CALL consumer#keyedLast() @L27 -> curried#at(T) boundary_lib FUNCTION_CALL consumer#widestFirst() @L31 -> curried#widest(readonly T[]) boundary_lib FUNCTION_CALL consumer#widestLast() @L35 -> curried#widest() +callback_registered METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> curried-local#()@L33 known_edge FUNCTION_CALL consumer#localDataFirst() @L56 -> curried-local#pluck(readonly T[],'name') known_edge FUNCTION_CALL consumer#localDataLast() @L59 -> curried-local#pluck('name') known_edge FUNCTION_CALL consumer#localKeyed(Row) @L62 -> curried-local#at(T,T) diff --git a/graph/test/typescript/expected/19-overload-gauntlet.edges b/graph/test/typescript/expected/19-overload-gauntlet.edges index a56fdb1ac..62ee92406 100644 --- a/graph/test/typescript/expected/19-overload-gauntlet.edges +++ b/graph/test/typescript/expected/19-overload-gauntlet.edges @@ -8,5 +8,6 @@ ambiguous_unknown FUNCTION_CALL consumer#viaMappedType(Readonly) @L34 -> - ambiguous_unknown FUNCTION_CALL consumer#viaRequired(Required) @L38 -> - ambiguous_unknown METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L52 -> - ambiguous_unknown METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L53 -> - +callback_registered FUNCTION_CALL consumer#viaContextualCallback(readonly Row[]) @L23 -> consumer#(?) known_edge FUNCTION_CALL consumer#viaInferredReturn() @L16 -> consumer#makeCount() known_edge FUNCTION_CALL consumer#viaInferredReturn() @L17 -> consumer#makeLabel() diff --git a/graph/test/typescript/expected/19-overload-gauntlet.lib.edges b/graph/test/typescript/expected/19-overload-gauntlet.lib.edges index b5604bc5f..30f1eb6d1 100644 --- a/graph/test/typescript/expected/19-overload-gauntlet.lib.edges +++ b/graph/test/typescript/expected/19-overload-gauntlet.lib.edges @@ -8,5 +8,6 @@ boundary_lib FUNCTION_CALL consumer#viaMappedType(Readonly) @L34 -> api#shape(Ce boundary_lib FUNCTION_CALL consumer#viaRequired(Required) @L38 -> api#shape(Cell) boundary_lib METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L52 -> Store#put(string,Row) boundary_lib METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L53 -> Store#put(string,Cell) +callback_registered FUNCTION_CALL consumer#viaContextualCallback(readonly Row[]) @L23 -> consumer#(?) known_edge FUNCTION_CALL consumer#viaInferredReturn() @L16 -> consumer#makeCount() known_edge FUNCTION_CALL consumer#viaInferredReturn() @L17 -> consumer#makeLabel() diff --git a/graph/test/typescript/expected/27-readonly-array-receiver.edges b/graph/test/typescript/expected/27-readonly-array-receiver.edges index a19575d39..eb4616e15 100644 --- a/graph/test/typescript/expected/27-readonly-array-receiver.edges +++ b/graph/test/typescript/expected/27-readonly-array-receiver.edges @@ -1,3 +1,4 @@ +callback_registered METHOD_CALL readonly#useEvery(readonly ((v: number) =[]) @L11 -> readonly#(?) known_edge FUNCTION_CALL readonly#(?) @L11 -> readonly#(number) known_edge METHOD_CALL readonly#mutablePush(string[]) @L27 -> Array#push(T) known_edge METHOD_CALL readonly#mutableSlice(string[]) @L22 -> Array#slice(number,number) diff --git a/graph/test/typescript/expected/27-readonly-array-receiver.envelope b/graph/test/typescript/expected/27-readonly-array-receiver.envelope deleted file mode 100644 index 17c00930a..000000000 --- a/graph/test/typescript/expected/27-readonly-array-receiver.envelope +++ /dev/null @@ -1 +0,0 @@ -value globals#@11:15 -> readonly# diff --git a/graph/test/typescript/expected/28-property-chain-container.edges b/graph/test/typescript/expected/28-property-chain-container.edges index e62a0689a..e5c7a9d31 100644 --- a/graph/test/typescript/expected/28-property-chain-container.edges +++ b/graph/test/typescript/expected/28-property-chain-container.edges @@ -1,3 +1,7 @@ +callback_registered METHOD_CALL Builder#viaField() @L17 -> Builder#(?) +callback_registered METHOD_CALL Builder#viaGetter() @L22 -> Builder#(?) +callback_registered METHOD_CALL Builder#viaLocal() @L28 -> Builder#(?) +callback_registered METHOD_CALL chain#viaAnnotated(Column[]) @L34 -> chain#(?) known_edge METHOD_CALL Builder#(?) @L17 -> Column#isVirtual() known_edge METHOD_CALL Builder#(?) @L22 -> Column#isVirtual() known_edge METHOD_CALL Builder#(?) @L28 -> Column#isVirtual() diff --git a/graph/test/typescript/expected/28-property-chain-container.envelope b/graph/test/typescript/expected/28-property-chain-container.envelope deleted file mode 100644 index 31bc2ad2f..000000000 --- a/graph/test/typescript/expected/28-property-chain-container.envelope +++ /dev/null @@ -1,4 +0,0 @@ -value globals#@2:16 -> chain# -value globals#@2:16 -> chain#Builder.@17 -value globals#@2:16 -> chain#Builder.@22 -value globals#@2:16 -> chain#Builder.@28 diff --git a/graph/test/typescript/expected/34-callback-of-callback.edges b/graph/test/typescript/expected/34-callback-of-callback.edges index cd263c396..d2ff99be3 100644 --- a/graph/test/typescript/expected/34-callback-of-callback.edges +++ b/graph/test/typescript/expected/34-callback-of-callback.edges @@ -1,3 +1,7 @@ +callback_registered CONSTRUCTOR_CALL exec#viaExecutor() @L12 -> exec#(?,?) +callback_registered CONSTRUCTOR_CALL exec#viaRenamedExecutor() @L20 -> exec#(?,?) +callback_registered FUNCTION_CALL exec#withSink((emit: Sink) =) @L29 -> exec#() +callback_registered METHOD_CALL exec#viaNamedParam(Payload[]) @L40 -> exec#(?) known_edge CONSTRUCTOR_CALL exec#(?,?) @L13 -> Payload#() known_edge CONSTRUCTOR_CALL exec#(?,?) @L21 -> Payload#() known_edge CONSTRUCTOR_CALL exec#(?) @L33 -> Payload#() diff --git a/graph/test/typescript/expected/34-callback-of-callback.envelope b/graph/test/typescript/expected/34-callback-of-callback.envelope index af22a1296..1fc52a968 100644 --- a/graph/test/typescript/expected/34-callback-of-callback.envelope +++ b/graph/test/typescript/expected/34-callback-of-callback.envelope @@ -1,5 +1,2 @@ value exec#@28:31 -> exec#@32 -value globals#@12:21 -> exec#@12 -value globals#@12:21 -> exec#@20 -value globals#@17:14 -> exec#@40 value_call exec#withSink -> exec#@32 diff --git a/graph/test/typescript/expected/38-dynamic-import.edges b/graph/test/typescript/expected/38-dynamic-import.edges index 944dc653e..1dd6e8c36 100644 --- a/graph/test/typescript/expected/38-dynamic-import.edges +++ b/graph/test/typescript/expected/38-dynamic-import.edges @@ -1,5 +1,6 @@ ambiguous_unknown METHOD_CALL r352#ctlComputedSpecifier(string) @L39 -> - ambiguous_unknown METHOD_CALL r352#viaThenCallback() @L27 -> - +callback_registered METHOD_CALL r352#viaThenCallback() @L27 -> r352#(?) intrinsic_terminal DYNAMIC_IMPORT_CALL r352#ctlComputedSpecifier(string) @L38 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL r352#viaLiteralBare() @L17 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL r352#viaLiteralDestructured() @L22 -> - diff --git a/graph/test/typescript/expected/41-library-generic-return.edges b/graph/test/typescript/expected/41-library-generic-return.edges index af6638f56..50c29bb94 100644 --- a/graph/test/typescript/expected/41-library-generic-return.edges +++ b/graph/test/typescript/expected/41-library-generic-return.edges @@ -1,5 +1,6 @@ ambiguous_unknown METHOD_CALL r356#ctlNonGenericLibReturn(string) @L28 -> - ambiguous_unknown METHOD_CALL r356#viaGenericLibReturn() @L35 -> - +callback_registered METHOD_CALL r356#viaGenericLibReturn() @L35 -> r356#(AlphaOp) known_edge CONSTRUCTOR_CALL r356#ctlClientGenericReturn() @L43 -> AlphaOp#() known_edge CONSTRUCTOR_CALL r356#makeAlpha() @L48 -> AlphaOp#() known_edge CONSTRUCTOR_CALL r356#viaGenericLibReturn() @L35 -> AlphaOp#() diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.edges b/graph/test/typescript/expected/69-named-and-inline-handlers.edges index cf666d4da..9f3b7b6cd 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.edges +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.edges @@ -1,6 +1,9 @@ ambient_terminal FUNCTION_CALL routes#() @L35 -> routes#it(string,() =) ambient_terminal FUNCTION_CALL routes#() @L50 -> routes#registerAll(Record string) ambiguous_unknown METHOD_CALL routes#() @L40 -> - +callback_registered FUNCTION_CALL routes#() @L35 -> routes#namedSpec() +callback_registered METHOD_CALL routes#() @L24 -> routes#() +callback_registered METHOD_CALL routes#() @L30 -> routes#namedHandler() known_edge FUNCTION_CALL routes#() @L24 -> routes#inlineWork() known_edge FUNCTION_CALL routes#() @L40 -> routes#notAHandler() known_edge FUNCTION_CALL routes#namedHandler() @L28 -> routes#namedWork() diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.envelope b/graph/test/typescript/expected/69-named-and-inline-handlers.envelope deleted file mode 100644 index 8b02681eb..000000000 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.envelope +++ /dev/null @@ -1 +0,0 @@ -value routes#@21:41 -> routes#namedSpec diff --git a/graph/test/typescript/expected/70-function-type-target-dispatch.edges b/graph/test/typescript/expected/70-function-type-target-dispatch.edges index 0001be686..619b9e47d 100644 --- a/graph/test/typescript/expected/70-function-type-target-dispatch.edges +++ b/graph/test/typescript/expected/70-function-type-target-dispatch.edges @@ -1,3 +1,6 @@ +callback_registered METHOD_CALL targets#viaAnnotatedParam(CB[]) @L14 -> targets#(?) +callback_registered METHOD_CALL targets#viaAsCast(unknown) @L19 -> targets#(?) +callback_registered METHOD_CALL targets#viaGenericMap(number[]) @L31 -> targets#(?)@L31 known_edge FUNCTION_CALL targets#(?) @L14 -> targets#(number) known_edge FUNCTION_CALL targets#(?) @L19 -> targets#(number) known_edge FUNCTION_CALL targets#viaForOf(CB[]) @L23 -> targets#(number) diff --git a/graph/test/typescript/expected/70-function-type-target-dispatch.envelope b/graph/test/typescript/expected/70-function-type-target-dispatch.envelope index 17ac3194b..1dd6161a1 100644 --- a/graph/test/typescript/expected/70-function-type-target-dispatch.envelope +++ b/graph/test/typescript/expected/70-function-type-target-dispatch.envelope @@ -1,5 +1,2 @@ -value globals#@4:14 -> targets#@14 -value globals#@4:14 -> targets#@19 -value globals#@4:14 -> targets#@31 value targets#@6:18 -> targets#@10 value targets#@6:18 -> targets#@8 diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges b/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges index f50f45644..178b6e843 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges @@ -1,3 +1,5 @@ +callback_registered FUNCTION_CALL middleware#dispatch(Entry[][],number) @L11 -> middleware#() +callback_registered METHOD_CALL pool#usePool(Pool) @L21 -> pool#() known_edge FUNCTION_CALL fetcher#get(string,Options) @L17 -> fetcher#(string) known_edge FUNCTION_CALL fetcher#load() @L21 -> fetcher#get(string,Options) known_edge FUNCTION_CALL middleware#boot() @L15 -> middleware#dispatch(Entry[][],number) diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope index baaffa735..fc04d0270 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope @@ -1,6 +1,5 @@ value fetcher#@4:21 -> fetcher# value middleware#@4:23 -> middleware#@7 -value pool#@6:11 -> pool#@21 value pool#Pool.run -> pool#@11 value pool#Pool.run -> pool#@16 value pool#Pool.run -> pool#@25 diff --git a/graph/test/typescript/expected/74-jsx-component-forms.edges b/graph/test/typescript/expected/74-jsx-component-forms.edges index 4bbd01501..887e1bbaf 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.edges +++ b/graph/test/typescript/expected/74-jsx-component-forms.edges @@ -7,6 +7,10 @@ ambiguous_unknown FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 ambiguous_unknown FUNCTION_CALL settings#Settings() @L6 -> - ambiguous_unknown METHOD_CALL app#App() @L15 -> - ambiguous_unknown METHOD_CALL parts#formatName(string) @L25 -> - +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() 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/74-jsx-component-forms.envelope b/graph/test/typescript/expected/74-jsx-component-forms.envelope index 3b3998b48..690fd675e 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.envelope +++ b/graph/test/typescript/expected/74-jsx-component-forms.envelope @@ -1,5 +1 @@ value parts#@18:21 -> parts# -value parts#@18:21 -> parts#UserCard -value parts#@20:47 -> parts#FieldImpl -value parts#@21:39 -> app# -value parts#@22:47 -> parts#buildDefault diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.edges b/graph/test/typescript/expected/75-jsx-wrapper-guards.edges index 68ca2dc39..78ebdfe74 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.edges +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.edges @@ -19,6 +19,18 @@ ambiguous_unknown METHOD_CALL app#() @L15 -> - ambiguous_unknown METHOD_CALL app#() @L16 -> - ambiguous_unknown METHOD_CALL app#() @L18 -> - ambiguous_unknown METHOD_CALL app#ArrowRenameTag({ icon: Fc }[]) @L57 -> - +callback_registered FUNCTION_CALL app#() @L15 -> app#() +callback_registered FUNCTION_CALL app#() @L16 -> app#() +callback_registered FUNCTION_CALL app#() @L18 -> app#() +callback_registered FUNCTION_CALL app#() @L19 -> app#() +callback_registered FUNCTION_CALL app#() @L21 -> app#() +callback_registered FUNCTION_CALL app#() @L22 -> app#() +callback_registered FUNCTION_CALL app#() @L29 -> app#MemoInner() +callback_registered FUNCTION_CALL app#() @L30 -> app#ObservedImpl() +callback_registered METHOD_CALL app#() @L15 -> app#(?) +callback_registered METHOD_CALL app#() @L16 -> app#(?) +callback_registered METHOD_CALL app#() @L18 -> app#(?) +callback_registered METHOD_CALL app#ArrowRenameTag({ icon: Fc }[]) @L57 -> app#(?,?) intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L15 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L16 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L18 -> - diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope b/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope index 4274ea0da..ae87f78fd 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope @@ -1,12 +1,4 @@ -value parts#@15:21 -> app#MemoInner -value parts#@15:21 -> app#ObservedImpl value parts#@15:21 -> parts# -value parts#@18:36 -> app#@15 -value parts#@18:36 -> app#@18 -value parts#@18:36 -> app#@21 -value parts#@19:39 -> app#@16 -value parts#@19:39 -> app#@19 -value parts#@19:39 -> app#@22 value parts#@22:30 -> app#@25 value parts#@26:35 -> app#@26 value_call app#Page -> app#MadeInner diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges index 5b6145e44..c0fb8e397 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges @@ -12,6 +12,12 @@ ambiguous_unknown FUNCTION_CALL parts#MemoFwdInner({},unknown) @L40 -> - ambiguous_unknown JSX_COMPONENT_CALL app#App() @L19 -> - ambiguous_unknown METHOD_CALL app#() @L6 -> - ambiguous_unknown METHOD_CALL app#makeName(number) @L26 -> - +callback_registered FUNCTION_CALL app#() @L10 -> parts#() +callback_registered FUNCTION_CALL app#() @L6 -> app#() +callback_registered FUNCTION_CALL app#() @L8 -> app#() +callback_registered FUNCTION_CALL defmemo#() @L4 -> parts#DefaultMemoInner() +callback_registered FUNCTION_CALL parts#() @L39 -> parts#MemoFwdInner({},unknown) +callback_registered METHOD_CALL app#() @L6 -> app#(?) intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L6 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L8 -> - known_edge FUNCTION_CALL app#() @L10 -> parts#pickOther(Fc) diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope index 539de9007..509328b85 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope @@ -1,7 +1,3 @@ value parts#@14:21 -> parts# -value parts#@14:21 -> parts#DefaultMemoInner value parts#@14:21 -> parts#NestedPickedInner value parts#@14:21 -> parts#PickedInner -value parts#@16:47 -> parts#MemoFwdInner -value parts#@17:36 -> app#@6 -value parts#@17:36 -> app#@8 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges new file mode 100644 index 000000000..4c3712657 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -0,0 +1,15 @@ +ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered METHOD_CALL app#doubled(number[]) @L16 -> app#(?) +callback_registered METHOD_CALL app#named(number[]) @L25 -> app#double(number) +callback_registered METHOD_CALL app#scaled(number[]) @L12 -> app#(?) +callback_registered METHOD_CALL app#viaLocal(number[]) @L31 -> app#log(number) +known_edge FUNCTION_CALL app#() @L38 -> app#record(number) +known_edge FUNCTION_CALL app#(?) @L48 -> app#record(number) +known_edge FUNCTION_CALL app#each(number[],(n: number) =) @L44 -> app#(number) +known_edge FUNCTION_CALL app#log(number) @L30 -> app#record(number) +known_edge FUNCTION_CALL app#useEach(number[]) @L48 -> app#each(number[],(n: number) =) +known_edge METHOD_CALL app#doubled(number[]) @L16 -> Array#map((v: T) =) +known_edge METHOD_CALL app#named(number[]) @L25 -> Array#map((v: T) =) +known_edge METHOD_CALL app#scaled(number[]) @L12 -> Array#map((v: T) =) +known_edge METHOD_CALL app#viaLocal(number[]) @L31 -> Array#forEach((v: T) =) diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries new file mode 100644 index 000000000..5c3d7421a --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module app#atStartup app.ts:37 + exported_from_entry_module app#doubled app.ts:15 + exported_from_entry_module app#each app.ts:43 + exported_from_entry_module app#named app.ts:24 + exported_from_entry_module app#scaled app.ts:11 + exported_from_entry_module app#useEach app.ts:47 + exported_from_entry_module app#viaLocal app.ts:29 + unimported_module app# app.ts:1 + unimported_module globals# globals.d.ts:1 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope new file mode 100644 index 000000000..234b7d617 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope @@ -0,0 +1,2 @@ +value app#@43:40 -> app#@48 +value_call app#each -> app#@48 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle new file mode 100644 index 000000000..be4f8be64 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle @@ -0,0 +1,7 @@ +78-hof-callback-at-library-boundary [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle new file mode 100644 index 000000000..f31a2b293 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -0,0 +1 @@ +oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use new file mode 100644 index 000000000..960a82a1b --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use @@ -0,0 +1,2 @@ +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle new file mode 100644 index 000000000..e7a4988d6 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle @@ -0,0 +1,7 @@ +78-hof-callback-at-library-boundary [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + contexts SUPER_TYPE=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py index e4cdb0361..291ccaa44 100644 --- a/graph/test/typescript/tools/normalize_edges.py +++ b/graph/test/typescript/tools/normalize_edges.py @@ -282,6 +282,10 @@ def main(): if pairs_only: if f[1] not in n.m or f[3] not in n.m: continue + # a function the site HANDS OVER (`xs.map(cb)`) is not a call the compiler lists at that + # site: the pairs are scored against the compiler, the .edges golden still carries it + if f[5] == 'callback_registered': + continue seen.add(f"{n.label(f[1])} -> {n.label(f[3])}") else: ln = site_line.get(f[0], '?') diff --git a/graph/typescript/engine/call-edge-generation/call_chain.dl b/graph/typescript/engine/call-edge-generation/call_chain.dl index 212c7fadc..565187993 100644 --- a/graph/typescript/engine/call-edge-generation/call_chain.dl +++ b/graph/typescript/engine/call-edge-generation/call_chain.dl @@ -124,6 +124,16 @@ call_chain_edge(ce, caller, "-", "-", "-", cls, kind) :- class_is_terminal("ambiguous_unknown"). class_is_terminal("intrinsic_terminal"). +// A function the site HANDS to a callee with no client body (resolution/value-flow.dl, +// handed_function): `xs.map(cb)`, `p.then(cb)`. Not the site's own callee, so its own +// tier, the one the JavaScript engine gives the same hand-over. +call_chain_edge(ce, caller, "-", m, "client", "callback_registered", kind) :- + handed_function(ce, m), + method_prov(m, "client"), + call_from(ce, caller), + caller != m, + invocation_site(ce, kind). + // ── 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 b64ea09d4..1989b9460 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -188,7 +188,50 @@ holder_value(v, e) :- var_initializer("client", _, e, v), e != "". holder_value(v, e) :- var_reassigned_value(v, e). holder_value(p, a) :- expr_child("client", ce, "ARGUMENT", pos, a), expr_resolves_to_method(ce, callee), # the overload the call SELECTS, not every candidate: - param_decl("client", _, pos, _, callee, p). # an arrow passed to on("close", …) is not a listener of every on + param_decl("client", _, pos, _, callee, p), # an arrow passed to on("close", …) is not a listener of every on + !hof_boundary_site(ce). # …and a parameter no body reads holds nothing (below) + +// ============================================================================ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY (the library boundary) +// +// `xs.map((x) => f(x))` where `map` is a declaration with no body in the client: a `.d.ts` +// interface member, an ambient `declare function`, an interface method nothing implements. +// Stored in the parameter, the arrow became a `value` candidate of the parameter's function +// type, and that ONE signature is shared by every `.map` call in the repository: a walk up +// from any map callback reached every caller of `.map`, and a walk down from any caller of +// `.map` reached every map callback. One constant read inside one arrow reached 1,078 +// callables in 198 files. No body can call through the parameter, so the envelope row +// said nothing true that the call site does not say better. +// +// The call SITE is what hands the function over, so the edge is the site's own: the +// function that writes `xs.map(cb)` reaches `cb` (call_chain.dl, `callback_registered`), +// and a callback passed at one site is never reached from another. +// +// Only where no CLIENT body runs at the site. A project function that calls its parameter +// keeps the holder rule: its body is what runs the callback, and that is a real call. +// ============================================================================ +call_runs_client_body(ce) :- call_runs_method(ce, m), method_prov(m, "client"). +hof_boundary_site(ce) :- call_site("client", _, _, _, _, ce, _), !call_runs_client_body(ce). + +value_branch(a, a) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + !expr_kind("client", "CALL_EXPRESSION", _, x), # `xs.map(make())` hands over what make RETURNS + expr_callable(x, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_referenced("client", k, h, x), + holder_ref_kind(k), + holder_holds_function(h, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + ts_field_access_target(x, f), + holder_holds_function(f, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_kind("client", "CALL_EXPRESSION", _, x), + call_runs(x, f), + holder_holds_function(f, m). // ── value_branch(Root, Expr): Expr is a value Root can evaluate to ── value_branch(e, e) :- holder_value(_, e). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 0210d202f..e5ffef4be 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -197,6 +197,9 @@ .decl value_branch(c0:symbol,c1:symbol) .decl binary_op_yields_operand(c0:symbol,c1:symbol) .decl holder_holds_function(c0:symbol,c1:symbol) +.decl call_runs_client_body(c0:symbol) +.decl hof_boundary_site(c0:symbol) +.decl handed_function(c0:symbol,c1:symbol) .decl called_through(c0:symbol,c1:symbol) .decl method_enclosing_member(c0:symbol,c1:symbol) .decl call_runs(c0:symbol,c1:symbol) diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/case.json b/tests/cases/typescript/hof-callback-at-library-boundary/case.json new file mode 100644 index 000000000..4f1d456b7 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/case.json @@ -0,0 +1,80 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a callback handed to a library higher-order function (a `map` with no body) is reached from the function that hands it over, and from no other caller of `map`: one shared function-type node made every `.map` callback reachable from every `.map` call", + "run": [ + "impact", + "RATE" + ], + "want": [ + "scaled src/app.ts:11", + "atStartup src/app.ts:37", + "useEach src/app.ts:47" + ], + "avoid": [ + "src/app.ts:15", + "src/app.ts:24", + "src/app.ts:29" + ] + }, + { + "why": "the function that writes `xs.map((x) => …)` reaches its own callback", + "run": [ + "path", + "scaled", + "src/app.ts:12" + ], + "want": [ + "1 of 1 target(s) reached" + ], + "avoid": [ + "Array.map" + ] + }, + { + "why": "and never a callback another function handed to the same `map`", + "run": [ + "path", + "doubled", + "src/app.ts:12" + ], + "want": [], + "avoid": [ + "1 of 1 target(s) reached" + ], + "expect_error": true + }, + { + "why": "CONTROL: a NAMED function handed to `map` is still reached, from the site that names it", + "run": [ + "path", + "named", + "double" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "CONTROL: a project higher-order function has a body that calls its parameter, so the callback it is handed is still reached through that call", + "run": [ + "path", + "each", + "src/app.ts:48" + ], + "want": [ + "1 of 1 target(s) reached", + "[dispatch]" + ], + "avoid": [ + "no chain of resolved calls" + ] + } + ] +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts new file mode 100644 index 000000000..198ebefae --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts @@ -0,0 +1,49 @@ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY. `map`, `forEach` and `onReady` are declarations +// only, so the site that hands the callback over is what reaches it: each function below reaches its own +// callback and nothing another function handed to the same `map`. +import { RATE } from './rate' + +const seen: number[] = [] +function record(n: number): void { + seen[0] = n +} + +export function scaled(xs: number[]): number[] { + return xs.map((x) => x * RATE) +} + +export function doubled(xs: number[]): number[] { + return xs.map((x) => x * 2) +} + +function double(n: number): number { + return n * 2 +} + +// a named function handed over: still reached, from the site that names it +export function named(xs: number[]): number[] { + return xs.map(double) +} + +// …and one held by a local first +export function viaLocal(xs: number[]): void { + const log = (n: number) => record(n) + xs.forEach(log) +} + +// an ambient declaration with no body anywhere: a host API that calls back +declare function onReady(cb: () => void): void + +export function atStartup(): void { + onReady(() => record(RATE)) +} + +// CONTROL: a project higher-order function has a body that calls its parameter, so the callback is +// still a dispatch candidate of the parameter's function type, reached through `each`'s own call. +export function each(xs: number[], fn: (n: number) => void): void { + for (const x of xs) fn(x) +} + +export function useEach(xs: number[]): void { + each(xs, (n) => record(n + RATE)) +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts new file mode 100644 index 000000000..32f96ef81 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts @@ -0,0 +1,15 @@ +// The slice of the standard library this case hands callbacks to. Declared here, as client code, so the +// client-only pass sees `map` and `forEach` as what they are to the project: signatures with no body. +interface Array { + map(fn: (v: T) => U): U[]; + forEach(fn: (v: T) => void): void; +} +interface Object {} +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts new file mode 100644 index 000000000..b8af0f524 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts @@ -0,0 +1 @@ +export const RATE = 3 diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json b/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/tests/cases/typescript/registered-handler/case.json b/tests/cases/typescript/registered-handler/case.json index 97ed399d0..c259a1474 100644 --- a/tests/cases/typescript/registered-handler/case.json +++ b/tests/cases/typescript/registered-handler/case.json @@ -10,6 +10,6 @@ "avoid": ["no dependent at any certainty"]}, {"why": "a declaration passed to anything else is reported as handed over as a value, never as a route: the receiving call is not named as one that calls it later, since that was wrong for every synchronous collection operation (#1166)", "run": ["impact", "auditLog"], - "want": ["names it as a value", "src/server.ts:7"], + "want": ["handed over as a value", "src/server.ts:7"], "avoid": ["registered as a", "the graph can see: nothing"]} ]} From 8f475ff324c1c4995a6c79e709fd2b9634ba5ff6 Mon Sep 17 00:00:00 2001 From: Swapnil Date: Mon, 28 Sep 2026 03:45:26 -0700 Subject: [PATCH 05/37] impact, test-impact: a call inside a mock stub is not a test route, and a request reaches the handler that answers its path and verb A call written inside a mocking library's stub or verification (when(x.m()), verify(x).m(), doReturn(v).when(x).m(), Moq Setup/Verify lambdas, NSubstitute Received()/Returns()) resolved to the real method and counted as a sound test route, though the receiver is a mock and the body never runs. Such a site is now the tier "stub": kept as a [stubs it] dependent, kept out of the closure and of path, and the tests that only stub the change (or reach it only through a type their class holds as a mock) are listed apart instead of counted. test-impact selects them only for a signature change or a removal. A handler's route is now its type's mapping prefix joined to its own mapping, and a route key joins only requests that send the handler's HTTP method; a path a handler registers verbatim is not also aliased to a parameterised sibling. When no test is counted and the change has framework-entered entry points, the answer says so and gives the search that finds those tests. --- plugins/axiomcode/skills/axiomcode/SKILL.md | 1 + .../axiomcode/reference/changed-and-tests.md | 6 + .../skills/axiomcode/reference/impact.md | 12 ++ .../skills/axiomcode/scripts/ax_edges.py | 144 +++++++++++++++++- .../axiomcode/scripts/ax_registration.py | 105 +++++++++++++ .../skills/axiomcode/scripts/axiomcode-impact | 125 +++++++++++++-- .../skills/axiomcode/scripts/axiomcode-path | 12 +- .../axiomcode/scripts/axiomcode-test-impact | 38 +++++ .../skills/axiomcode/scripts/dl/impact.dl | 70 +++++++-- .../skills/axiomcode/scripts/graph_sql.py | 99 +++++++++--- skills/axiomcode/SKILL.md | 1 + .../axiomcode/reference/changed-and-tests.md | 6 + skills/axiomcode/reference/impact.md | 12 ++ .../Shop.Tests/PricesTests.cs | 77 ++++++++++ .../Shop/Prices.cs | 32 ++++ .../mock-stub-is-not-a-test-route/case.json | 61 ++++++++ .../mock-stub-is-not-a-test-route/case.json | 90 +++++++++++ .../src/shop/Checkout.java | 13 ++ .../src/shop/CheckoutStubTest.java | 19 +++ .../src/shop/CheckoutTest.java | 12 ++ .../src/shop/CheckoutWithMockTest.java | 15 ++ .../src/shop/FixtureStubTest.java | 28 ++++ .../src/shop/Ids.java | 7 + .../src/shop/Payloads.java | 7 + .../src/shop/PriceService.java | 7 + .../src/shop/PriceServiceTest.java | 13 ++ .../src/shop/RequestTest.java | 12 ++ .../case.json | 13 ++ .../src/shop/OrdersController.java | 25 +++ .../src/shop/OrdersCreateTest.java | 22 +++ .../src/shop/OrdersListTest.java | 22 +++ 31 files changed, 1061 insertions(+), 45 deletions(-) create mode 100644 tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs create mode 100644 tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs create mode 100644 tests/cases/csharp/mock-stub-is-not-a-test-route/case.json create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/case.json create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java create mode 100644 tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java create mode 100644 tests/cases/java/route-under-type-prefix-and-verb/case.json create mode 100644 tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java create mode 100644 tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java create mode 100644 tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 2cc15b7d2..6e827fa05 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -58,6 +58,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 906e52d4c..074ac4321 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -95,3 +95,9 @@ the number in front of you. Skipping what it does not name is a decision about r you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear here (the `[text]` tier above recovers the ones whose test names the file it loads). +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. + diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index de376f493..cf2eb57e2 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -179,6 +179,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 17323fd82..daa83158c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -30,7 +30,7 @@ reaches B in 11 calls" is false when five of the eleven are containment. `calls_in()` is what a hop count is taken from. """ -import re +import collections, re # ── how certain a hop is. Lower is more certain; the reader prefers the lowest at every step. ────── TIER_RANK = { @@ -56,6 +56,7 @@ 'ambiguous_dynamic': 8, # a call through C# `dynamic`: undecidable from source by design 'ambiguous_unknown': 8, 'by-name': 9, # not resolved at all: the names simply match + 'stub': 9, # written inside a mock's stub or verification: named, never run (stub_sites) } UNRANKED = 10 # an engine tier this table has not been taught — least certain, never silent @@ -82,6 +83,7 @@ 'fan_capped': 'the candidate set was too large to enumerate — a sample, not the set', 'written': 'the call is written at that line', 'by-name': 'unresolved — the names match and nothing more', + 'stub': 'written inside a mock\'s stub or verification; the real method does not run there', } # ── what the hop IS, in one word that means the same thing in every language ─────────────────────── @@ -160,6 +162,7 @@ def legend(tiers): 'callback_registered': 'registered', 'event_dispatch': 'registered', 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered', 'fan_capped': 'capped set', + 'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run } DIRECT_CERT_DEFAULT = 'registered' # unlisted: an edge the engine asserted and this table cannot name — never `resolved` @@ -167,6 +170,7 @@ def legend(tiers): DIRECT_WHY = { 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', + 'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change', } # …and where the TIER says something more specific than its certainty. A request or event is not handed over as a # value (a JavaScript callback's wording): the dependent sends it, and a framework runs the handler for what is sent. @@ -225,12 +229,12 @@ def entry_outside(reason): # instead: the membership is exactly what it was before this table existed, so no row leaves any # set — only the label it is printed under changes. It matters most for the --delete verdict, where # dropping a hand-off would turn "something still holds this" into "safe to delete". -EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set'}) +EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'}) # most certain first. A caller with several call sites to the same callee can hold sites of different # tiers; a summary that names the caller once takes the best of them, which is the honest reading of # "at least one resolved call exists here". -DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set') +DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it') # ── `defines`: a callable written inside another one's body ──────────────────────────────────────────────── @@ -319,6 +323,140 @@ def defines_edges(callables, sites=None): return [(p, i, 'defines') for (_, i), p in parent.items()] +# ── a STUB SITE: a call written inside a mocking library's stub or verification ───────────────────────────── +# `when(repo.find(id)).thenReturn(x)`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(id))`, +# `sub.Received().Save(x)`, `sub.Find(id).Returns(x)`. The engine resolves `repo.find` to the declared method, which +# is right about the NAME (a rename or a new parameter breaks the test) and wrong about EXECUTION: the receiver is a +# mock, so the real body never runs there. Walked as a call, every stub was a `[sound]` route from the test to the +# real method, and "which tests run this body" was answered with the tests that replace it. Measured on a Spring REST +# project: all 8 tests named for a service method stubbed it on a @MockBean, and none of them ran it. +# +# A site is a stub site by its POSITION against the wrapper's own call site (the parser records both spans), never by +# its own name, and only when the wrapper did NOT resolve to a client declaration: a project method called `when` or +# `Setup` is not the library's. Three shapes, one knob table per language: +# arg the stubbed call is the wrapper's ARGUMENT, the outermost call in it: `when(x.m(a()))` marks m and not a(), +# which runs for real to build the stub's argument; `mock.Setup(x => x.M())` marks M +# prefix the wrapper's RESULT is the stubbed call's receiver: `verify(x).m()`, `sub.Received(1).M()`. Mockito's +# `doReturn(v).when(x).m()` is this shape too, and `when` counts here only behind a do* call, because an HTTP +# test DSL writes a request as `given().when().get("/orders")` and that `get` is the request, not a stub +# recv the stubbed call is the wrapper's RECEIVER: NSubstitute's `sub.M(a).Returns(v)` +# Python has no row on purpose: unittest.mock replaces an attribute by STRING (`patch("pkg.mod.f")`, +# `patch.object(C, "m")`) and a MagicMock receiver is untyped, so no call on a mock resolves to a client method. +STUB_WRAPPERS = { + 'java': { + 'arg': {'when', 'given'}, # Mockito.when, BDDMockito.given + 'prefix': {'verify', 'should'}, # verify(x).m(), then(x).should().m() + 'prefix_after_do': {'when'}, # doReturn(v).when(x).m() + 'recv': set(), + 'lambda_arg': False, + }, + 'csharp': { + # Moq's mock.Setup(x => x.M()), FakeItEasy's A.CallTo(() => f.M()). The stubbed call is written in a LAMBDA + # (lambda_arg below): a snapshot library's `Verify(await svc.Get())` runs Get for real, and a BDD runner's + # `.When(x => GetOrders())` runs its lambda, so `When` is not in the table at all + 'arg': {'Setup', 'SetupGet', 'SetupSet', 'SetupSequence', 'SetupProperty', 'Verify', 'VerifyGet', 'VerifySet', + 'CallTo', 'CallToSet'}, + 'lambda_arg': True, + 'prefix': {'Received', 'DidNotReceive', 'ReceivedWithAnyArgs', 'DidNotReceiveWithAnyArgs'}, # NSubstitute + 'prefix_after_do': set(), + 'recv': {'Returns', 'ReturnsForAnyArgs', 'ReturnsNull', 'ReturnsNullForAnyArgs', 'Throws', 'ThrowsAsync', + 'ThrowsForAnyArgs', 'ThrowsAsyncForAnyArgs'}, # NSubstitute + }, +} +STUB_DO = {'doReturn', 'doThrow', 'doNothing', 'doAnswer', 'doCallRealMethod'} +STUB_LANG = {'java': 'java', 'kt': 'java', 'groovy': 'java', 'cs': 'csharp'} +STUB_TIER = 'stub' + + +def _short(name): + """a call site's callee as written, reduced to its simple name: `Mockito.when` -> when, `Substitute.For` -> For""" + n = re.sub(r'<.*$', '', (name or '').replace('()', '')).strip() + return n.rsplit('.', 1)[-1] + + +def _stub_lang(f): + return STUB_LANG.get((f or '').rsplit('.', 1)[-1]) if '.' in (f or '') else None + + +def stub_sites(q): + """the ids of the call sites written inside a mocking library's stub or verification (STUB_WRAPPERS above). + q(sql, params) -> rows. Only the files holding a wrapper-named call site are read.""" + names = {n for lang in STUB_WRAPPERS.values() for k in ('arg', 'prefix', 'prefix_after_do', 'recv') for n in lang[k]} + files = sorted({f for f, n in q("SELECT DISTINCT file_path, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()) + if _stub_lang(f) and _short(n) in names}) + out = set() + for f in files: + lang = STUB_WRAPPERS[_stub_lang(f)] + rows = [(r[0], _short(r[1]), (r[2], r[3], r[4], r[5]), bool(r[6]), bool(r[7])) for r in q( + """SELECT s.id, s.callee_name, s.start_line, s.start_column, s.end_line, s.end_column, + EXISTS (SELECT 1 FROM call_edges e WHERE e.call_site_id = s.id AND e.callee_provenance = 'client' + AND e.callee_method_id IS NOT NULL), + EXISTS (SELECT 1 FROM symbols y WHERE y.id = s.caller_id AND y.display LIKE '%%') + FROM call_sites s WHERE s.file_path = ?""", (f,)) if None not in r[2:6]] + spans = [(i, sp) for i, _, sp, _, _ in rows] + in_lambda = {i for i, _, _, _, lam in rows if lam} + wraps = [(n, sp) for _, n, sp, client, _ in rows if not client] # the LIBRARY's wrapper, never a project method + def outermost(cands): # the candidates no other candidate encloses + return {i for i, sp in cands if not any(_within(sp, o) for _, o in cands)} + def nearest(cands): # the candidates enclosing no other candidate + return {i for i, sp in cands if not any(_within(o, sp) for _, o in cands)} + for n, w in wraps: + if n in lang['arg']: + # inside the wrapper's span and NOT inside its receiver chain, which starts where the wrapper starts: + # `given().body(new HashMap<>() {…}).when().put("/orders/1")` holds the HashMap inside `when`'s span, as + # the receiver's argument, not as when's + recv = [sp for _, sp in spans if _within(sp, w) and sp[:2] == w[:2]] + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] > w[:2] + and not any(sp == r or _within(sp, r) for r in recv) + and (i in in_lambda or not lang['lambda_arg'])]) + if n in lang['prefix'] or (n in lang['prefix_after_do'] and + any(x in STUB_DO and _within(sp, w) and sp[:2] == w[:2] for x, sp in wraps)): + out |= nearest([(i, sp) for i, sp in spans if _within(w, sp) and sp[:2] == w[:2]]) + if n in lang['recv']: + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] == w[:2]]) + return out + + +# ── a MOCKED TYPE: a test class that holds a mock of T never runs T's methods ──────────────────────────────────── +# `@MockBean OrderService orders` in a web test replaces the bean the controller is handed, so a request the test sends +# reaches the controller and stops at the mock: a change to OrderService's body cannot fail that test, however the +# route to it is drawn. Read off the test class's FIELDS (and those of the classes it extends): a mock-making +# annotation on the field, a Moq `Mock` field type, or a mock factory written in the field's initializer. A spy +# (@Spy, @SpyBean, Substitute.ForPartsOf) runs the real methods and is not a mock here; neither is @InjectMocks, the +# object under test. A mock held in a LOCAL is not read (no fact records a local's initializer), so a test that builds +# its mocks inside the method keeps its routes. +MOCK_FIELD_DECOR = {'Mock', 'MockBean', 'MockitoBean'} +MOCK_FACTORY = {'mock', 'For', 'Fake'} # Mockito.mock(T.class), Substitute.For(), A.Fake() +_MOCK_GENERIC = re.compile(r'^(?:\w+\.)*Mock<\s*([\w.]+)') + + +def _simple_type(t): + return re.sub(r'<.*$', '', (t or '').strip()).split('.')[-1] + + +def mocked_types(q, line_of=None): + """{test type simple name: {mocked type simple name}} from the fields a test class declares. q(sql, params); + line_of(file, line) -> the source line, read only where the parser kept `Mock` and dropped its type argument (C#).""" + try: + fields = q("SELECT id, type_name, owner_qualified_name, file_path, start_line FROM fields WHERE owner_qualified_name IS NOT NULL", ()) + except Exception: + return {} + dec = collections.defaultdict(set) + for oid, name in q("SELECT owner_id, name FROM decorations WHERE owner_id IS NOT NULL", ()): + dec[oid].add((name or '').split('.')[-1]) + fac = collections.defaultdict(set) + for f, l, n in q("SELECT file_path, start_line, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()): + if _short(n) in MOCK_FACTORY: fac[(f, l)].add(_short(n)) + out = collections.defaultdict(set) + for fid, tn, owner, f, l in fields: + m = _MOCK_GENERIC.match(tn or '') + if not m and _simple_type(tn) == 'Mock' and line_of and f and l: + m = re.search(r'\bMock<\s*([\w.]+)', line_of(f, l) or '') + t = m.group(1).split('.')[-1] if m else (_simple_type(tn) if (dec.get(fid, set()) & MOCK_FIELD_DECOR or fac.get((f, l))) else None) + if t: out[owner.split('.')[-1]].add(t) + return dict(out) + + def best_cert(certs): return min(certs, key=lambda c: DIRECT_ORDER.index(c) if c in DIRECT_ORDER else len(DIRECT_ORDER)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index c452c453c..6b9f12166 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -210,9 +210,114 @@ def decoration_keys(q, site_file=None): why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') out.append((owner, sf(f) if f else '', l or 0, kind, key, why)) + out += _prefixed_routes(q, out, sf) return sorted(set(out)) +# A HANDLER'S ROUTE IS ITS TYPE'S PREFIX AND ITS OWN PATH. `@RequestMapping("/orders/{id}")` on the class and a bare +# `@GetMapping` on the method serve "/orders/{id}"; `@PostMapping("lines")` beside it serves "/orders/{id}/lines"; a +# path written without its leading slash (`@RequestMapping("widgets")`) is served from the root all the same. Read one +# decoration at a time, the prefix registered the CLASS, which no closure walks, and a relative path registered +# nothing, so a test driving such a handler over HTTP reached nothing: a request test for a class-mapped controller +# counted 0 tests. A decoration is a mapping when its NAME says so (…Mapping, JAX-RS @Path, a verb, ASP.NET's +# [Route] / [HttpGet]); a `@Transactional` beside it is not a route. ASP.NET's [controller] and [action] tokens are +# the type's name without its Controller suffix and the method's name. +import collections, re +_MAPPING = re.compile(r'^(\w*Mapping|Path|GET|POST|PUT|DELETE|PATCH|Http(Get|Post|Put|Delete|Patch)|Route)$') + + +def _first_path(text): + m = re.search(r'"([^"\s]{0,120})"', (text or '').replace('"""', '"')) + return m.group(1) if m else None + + +def _join(a, b): + return '/' + '/'.join(x.strip('/') for x in (a, b) if x and x.strip('/')) + + +def _prefixed_routes(q, rows, sf): + if not _has(q, 'symbols'): + return [] + seen = {(r[0], r[4]) for r in rows} + sym = {i: (d, n, o, bool(t) and not m) for i, d, n, o, t, m in q("SELECT id, display, name, owner, type_id, method_id FROM symbols WHERE display IS NOT NULL")} + prefix = collections.defaultdict(set) # type display -> the paths its own mapping serves + methods = [] + for owner, name, text, f, l in q("SELECT owner_id, name, text, file, line FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + if not _MAPPING.match(short) or owner not in sym: continue + p_ = _first_path(text) + d, n, o, is_type = sym[owner] + if is_type: + if p_: prefix[d].add(p_) + else: + methods.append((owner, short, p_, f, l)) + out = [] + for mid, short, p_, f, l in methods: + d, n, o, _t = sym[mid] + tname = (o or '').split('.')[-1] + for pre in sorted(prefix.get(o) or {''}): + key = _join(pre, p_ or '') + key = key.replace('[controller]', re.sub(r'Controller$', '', tname).lower()).replace('[action]', (n or '').lower()) + if key == '/' and not (pre or p_): continue + if (mid, key) in seen: continue + seen.add((mid, key)) + out.append((mid, sf(f) if f else '', l or 0, 'route', key, + f'registered as a route "{key}" by @{short}' + (" under its type's prefix" if pre else '') + ' — the router calls it, no call site does')) + return out + + +# THE HTTP METHOD A ROUTE ANSWERS, AND THE ONE A TEST SENDS. "/orders" is two handlers when one answers GET and the other +# POST, and a test that posts an order drives only the second. Joined on the path alone, every test that lists orders +# reached the handler that creates one, and a path five tests write (one per verb) was refused as a key that +# identifies nothing. The verb is read where it is written: the handler's decoration (`@GetMapping`, `[HttpPost]`, +# `@router.put`, `@RequestMapping(method = DELETE)`, JAX-RS `@DELETE` beside `@Path`) and the request call that +# carries the path literal (`.put("/orders/{id}", id)`, `client.delete(...)`, `DeleteAsync(...)`). Either side unknown +# joins as before. +_VERBS = ('GET', 'POST', 'PUT', 'DELETE', 'PATCH') +_VERB_NAME = re.compile(r'^(?:(Get|Post|Put|Delete|Patch)Mapping|Http(Get|Post|Put|Delete|Patch)|(GET|POST|PUT|DELETE|PATCH)|(get|post|put|delete|patch))$') + + +def route_verbs(q, site_file=None): + """{(decl, key, VERB)}: the HTTP method each decoration-registered route answers, where a decoration says it.""" + if not _has(q, 'decorations'): + return set() + verbs = collections.defaultdict(set) + for owner, name, text in q("SELECT owner_id, name, text FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + m = _VERB_NAME.match(short) + if m: + verbs[owner].add(next(g for g in m.groups() if g).upper()) + elif _MAPPING.match(short) or short == 'route': + for v in re.findall(r'\b(GET|POST|PUT|DELETE|PATCH)\b', text or ''): verbs[owner].add(v) + out = set() + for decl, _f, _l, kind, key, _w in decoration_keys(q, site_file): + if kind == 'route': + for v in verbs.get(decl, ()): out.add((decl, key, v)) + return out + + +def literal_verbs(q, at, site_file=None): + """{(caller, literal, VERB)}: a path literal written as the argument of a request call named for one HTTP method — + the innermost such call whose span holds the literal's line.""" + if not (_has(q, 'literals') and _has(q, 'call_sites')): + return set() + sf = site_file or (lambda x: x) + calls = collections.defaultdict(list) + for n, f, a, b in q("SELECT callee_name, file_path, start_line, end_line FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"): + short = re.sub(r'Async$', '', (n or '').split('.')[-1].split('<')[0]).upper() + if short in _VERBS: calls[sf(f) if f else ''].append((a, b or a, short)) + out = set() + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + if not (isinstance(v, str) and v.startswith('/')): continue + f2 = sf(f) if f else '' + hold = [(b - a, -a, verb) for a, b, verb in calls.get(f2, ()) if a <= l <= b] + if not hold: continue + verb = min(hold)[2] + c = at(f, l) + if c: out.add((c, v, verb)) + return out + + # ── a registration written as a CALL that no verb list knows ───────────────────────────────────────────────── # the Python web framework's `application.add_url_rule("/quote/", view_func=legacy_quote)` is a route registration whose verb # is not an HTTP verb, and the same shape appears wherever a framework takes (path, handler) under a name of its own diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 406b58015..1a3acbe37 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -112,8 +112,10 @@ def ckey(k): # and it is not a call to the declaration at all, so it cannot outrank a target set. # `framework` (#1509) sits directly below `remote` for the same reason: the engine joined both ends on the # framework's own registration, so it outranks every name match, and it is not a call to the declaration. +# `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match, +# and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, - 'in scope': 6, 'by name': 7, 'text': 8, 'alongside': 9} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} def also_text(whys, shown=2): """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" if not whys: return '' @@ -696,7 +698,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '32' # 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 = '33' # 33: 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 @@ -871,12 +873,16 @@ class Impact: # on cert_tier, and a tier with no row would drop its dependents out of the answer entirely rather # than mislabel them, which is the worse of the two failures. ax_edges.direct_cert has the default. W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.direct_why(t)) - for (t,) in g.q("SELECT DISTINCT tier FROM call_edges")})) - W('calls', [(r['caller_id'], r['callee_method_id'], r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT 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")]) + for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER}})) + # a call written inside a mock's stub or verification keeps its row and loses its tier: "stub" (ax_edges.stub_sites) + stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() + 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 '', 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")]) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') - W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) + # is the kind "stub": still a name match for the rows, never a by-name seed of the closure + W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else ax_edges.STUB_TIER if r['id'] in stubs else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("SELECT s.id, s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) # a call site with a name that the engine did NOT bind to a client declaration: unresolved, or sent to a library # because the callee is generated (a dataclass constructor is a call to the class itself). The generated-member # rules look here, so a generated accessor or constructor is found whichever way the engine classified the site @@ -944,6 +950,9 @@ class Impact: + ax_registration.registrations(g.q, g.site_file)): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) + W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) + W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) # THE NAME A DECORATOR REBINDS. `@audited def summarise(...)` leaves the name `summarise` denoting what # `audited(summarise)` RETURNED — the wrapper — and the engine resolves that (ext_decorated_name_target). # Every caller written `summarise(...)` therefore runs the wrapper, and a change to the wrapper reaches @@ -1406,7 +1415,7 @@ class Impact: prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -1777,6 +1786,10 @@ def main(argv): inh = {} for s_, m, d in {(x[0], x[1], int(x[2])) for x in res['inherited_test']}: if m in tests and int(d) <= depth and s_ in g.sym: inh.setdefault(s_, []).append(m) + # a test that only STUBS the change on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): it names the + # declaration, so a rename or a new parameter breaks it, and never runs it, so it is not a route and not counted + stubbed = sorted({x[0] for x in res.get('test_stub', []) if x[0] in g.sym and x[0] not in tests}, + key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: @@ -1788,13 +1801,51 @@ def main(argv): universe = sum(1 for _ in open(os.path.join(g.facts, 'impact', 'test_method.facts'))) # an `alongside` row is not a place the change is observed from: it must stay out of the entry points, as it stays out # of the seeds — a reader is promised that an entry point reaches the change (#762) - direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside'} | {c for c, _ in contract} + # …nor is a stub on a mock: it names the declaration and runs none of it + direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside' and cert != 'stubs it' + and not why.startswith('stubs a method of this name')} | {c for c, _ in contract} direct_ids = {c for c in direct_ids if c in g.sym} ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if not callers_of.get(m) or g.sym[m]['is_test']] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} + # A TEST THAT HOLDS A MOCK OF A TYPE ON ITS ROUTE never runs that type's methods (ax_edges.mocked_types): a web test + # with `@MockBean OrderService` reaches the controller over HTTP and stops at the mock, so a route through + # OrderService is not a route, whatever its rung. Read off the route the answer prints (the nearest one); the test + # moves to the stub listing, named for the type it mocks. + mock_via = {} + if tests: + def _line_of(f, l): + try: return I.lines(g.site_file(f))[l - 1] + except Exception: return '' + mocks = ax_edges.mocked_types(lambda s_, p_: g.q(s_, *p_), _line_of) + if mocks: + tsimple = {r['type_id']: (r['display'] or '').split('.')[-1] for r in g.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + anc = collections.defaultdict(set) + for r in g.q("SELECT type_id, ancestor_type_id FROM type_ancestors") if g.has('type_ancestors') else []: + if r[0] in tsimple and r[1] in tsimple: anc[tsimple[r[0]]].add(tsimple[r[1]]) + up_names = lambda n: {n} | anc.get(n, set()) + for m in list(tests): + own = (g.sym[m]['owner'] or '').split('.')[-1] + held = set().union(*(mocks.get(x, set()) for x in up_names(own))) + if not held: continue + via = tests[m][1] + ch = (chains.get(m) or []) if not via else [m] + I.chain(via, parent) + # the hop's OWN type, or a supertype of it only where the hop is a dispatch choice (the caller's call was + # resolved to the mocked interface, and the implementation is one of its candidates). A real CachingStore + # wrapping a mocked IStore implements that interface too, and a test that calls the real one + # directly reaches it through a resolved call, which is not through the mock. + for a_, n in zip(ch, ch[1:]): + ow = ((g.sym.get(n) or {}).get('owner') or '').split('.')[-1] + if not ow: continue + ts = {t for x, t in parent.get(a_, ()) if x == n} + hit = ({ow} | (anc.get(ow, set()) if ts & {'dispatch', 'multi_inferred'} else set())) & held + if hit: + mock_via[m] = sorted(hit)[0]; break + for m in mock_via: + tests.pop(m, None); chains.pop(m, None) + stubbed = sorted(set(stubbed) | set(mock_via), key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) # how sure is each test's route to the change? the worst hop on its chain — what makes the answer wide is visible here: # a call resolved to a SET of targets, or an override reached through its base, is the engine's over-approximation # a tier no rung is written for, collected as the routes are labelled so it can be SAID rather than @@ -1926,6 +1977,22 @@ def main(argv): hops += 1 if not any((c, t) in real for t in asserted.get(c, ())): bad += 1 prose_hits = getattr(getattr(I, '_ns', None), 'prose', set()) + # the closure's own framework-entered entry points (not the target: `unmod` speaks for it), with the route each + # serves when a decoration registers one, and one search for the tests that drive them + fw_ent, fw_grep = [], '' + _ent_ids = [m for m, _d in sorted(ent, key=lambda x: (x[1], g.disp(x[0]))) if m not in set(seeds) and not g.sym[m]['is_test']] if not tests else [] + if _ent_ids: + _routes = collections.defaultdict(set) + for rd, _f, _l, kind, key, _w in ax_registration.decoration_keys(g.q, g.site_file): + if kind == 'route' and rd in _ent_ids: _routes[rd].add(key) + for m in _ent_ids: + own = [sig for _i, sig in P.unmodelled_entry(g, [m]) if ' on its type ' not in sig and 'does not contain' not in sig] + if own or _routes.get(m): + fw_ent.append((m, (own[0].split(' on ')[0] if own else '') + (f' "{sorted(_routes[m])[0]}"' if _routes.get(m) else ''))) + pre = sorted({re.split(r'[{<:]', k)[0] for m, _ in fw_ent for k in _routes.get(m, ()) if len(re.split(r'[{<:]', k)[0]) > 1})[:4] + names = sorted({g.sym[m]['name'] for m, _ in fw_ent if g.sym[m].get('name')})[:4] + fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ + ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') if as_json: print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], @@ -1935,6 +2002,10 @@ def main(argv): 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], + 'framework_entries': [{'id': m, 'display': g.disp(m), 'signal': sig, 'at': g.loc(m)} for m, sig in fw_ent], + 'framework_grep': fw_grep, + 'stub_tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'at': g.loc(m), 'certainty': 'stubs it', + **({'mocks': mock_via[m]} if m in mock_via else {})} for m in stubbed], 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], @@ -2135,6 +2206,14 @@ def main(argv): srch = (f'grep -rn "\\.{nm[0]}(" {P.grep_roots(g)}' if len(nm) == 1 else P.grep_for(g, nm[0]) if nm else '') print(f" +{len({c for c, *_ in collapsed})} [by name] (the same method name on an untyped receiver; may be another method)" + (f" — {srch} lists them" if srch else '') + "; --page all or --limit N prints each row") + # a test callable that only STUBS it on a mock names it and runs none of it: said apart, never as "reach it" + tstub = [r for r in tst if r[3] == 'stubs it' or r[2].startswith('stubs a method of this name')] + tst = [r for r in tst if r not in tstub] + if tstub and not want_tests: + sbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tstub if r[4]) + print(f" +{len({c for c, *_ in tstub})} test callable(s) in {len(sbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in sbyf.most_common(4)) + + (' …' if len(sbyf) > 4 else '') + " only stub it on a mock (when/verify, Setup/Verify, Received/Returns): they name it and" + " run none of it, so they break on a rename or a new parameter and never on a body change") if tst and not want_tests: # the count names its files: a pointer that says only "+31" leaves the reader not knowing what was cut or # where, which is what the crowding rule exists to prevent (tests/cases/typescript/anonymous-crowding) @@ -2227,7 +2306,25 @@ def main(argv): if not tests and unmod: print(f" NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how, so a test that enters " f"through that framework (a request, a started context, an event) reaches it with no route counted here: {unmod_grep}") + # …and the same for what REACHES it: an entry point of this closure that a framework enters (a route handler, a + # listener, a message handler) is driven by tests through the framework — a request, an event, a mediator send — + # and a route is counted only where a key or an edge joins the two. Said, with the search that finds those tests, + # rather than letting the count stand as if it were the whole set. + # only where the count is 0: beside counted tests it is one more line on every answer that crosses a web layer, and + # the claim it corrects ("no test reaches it") is not being made + if fw_ent and not tests: + shown = ', '.join(f"{g.disp(m)} {sig}" for m, sig in fw_ent[:3]) + (f" … +{len(fw_ent) - 3}" if len(fw_ent) > 3 else '') + print(f" NOT COUNTED: {len(fw_ent)} entry point(s) of this change are entered by a framework ({shown}) — a test that drives" + f" them through it (a request, an event, a mediator send) reaches the change with no route here unless a [by key] row" + f" says so, so the count is a lower bound; to find those tests: {fw_grep}") if weak_tests: print(f" {len(weak_tests)} of them only through a dependent that is not an exact edge") + if stubbed: + sbf = collections.Counter(g.sym[m]['file'] for m in stubbed) + mk = sorted({mock_via[m] for m in stubbed if m in mock_via}) + print(f" [stubs it] {len(stubbed)} more test(s) only stub it on a mock (when/verify, Setup/Verify, Received/Returns)" + + (f" or reach it only through a type they hold as a mock ({', '.join(mk[:3])}{' …' if len(mk) > 3 else ''})" if mk else '') + + " and never run it: not counted above, since a body change cannot fail them; a rename or a new parameter does — " + + ', '.join(f"{f} ({n})" for f, n in sbf.most_common(4)) + (f" … +{len(sbf) - 4}" if len(sbf) > 4 else '')) if tests: byc = collections.Counter(test_cert.values()) print(" how sure each route is: " + ', '.join(f"{n} {c}" for c, n in sorted(byc.items(), key=lambda x: CERT_RANK[x[0]])) + f" — {len(sure)} reach it through resolved single-target calls within 3 hops") @@ -2279,6 +2376,15 @@ def main(argv): else: print(f" {f} ({len(ms)}){via}: " + ', '.join(tname(m) for m in shown) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) if not want_why: print(" --why adds each test's route (with --tests-in to keep it short)") + if want_tests and stubbed: + sfiles = collections.defaultdict(list) + for m in stubbed: + if not tests_in or tests_in in (g.sym[m]['file'] or ''): sfiles[g.sym[m]['file']].append(m) + if sfiles: + print(f" [stubs it] {sum(len(ms) for ms in sfiles.values())} test(s) in {len(sfiles)} file(s), not counted: each stubs it on a mock and never runs it" + " (they break on a rename or a new parameter, never on a body change):") + for f, ms in sorted(sfiles.items(), key=lambda kv: (-len(kv[1]), kv[0])): + print(f" {f} ({len(ms)}): " + ', '.join(tname(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) # --depends: the downstream direction (#761). `impact` walks callers only, so what the target itself calls is # never reported, and that is what an agent editing it has to change alongside. Same edges, read the other way; # the same certainty vocabulary, printed as its own section so the two questions are never confused. @@ -2454,6 +2560,7 @@ def main(argv): if decs: bits.append("decorations on it: @" + ', @'.join(decs[:5]) + " — a framework may call it with no call site, and a decorator can hand the wrapper to the caller instead of this declaration") if u: bits.append(f"{u} unresolved call(s) inside the impacted set") if tests: bits.append(f"{len(tests)} test(s) reach it") + if stubbed: bits.append(f"{len(stubbed)} test(s) stub it on a mock or hold its type as one ([stubs it] above): deleting it breaks the stubs they write, though no body change fails them") for b in bits: print(f" · {b}") if not bits and not calls and not contract and not weak and not ov and not ext and not fw_why: print(" · nothing — no by-name match, no literal, no decoration, no unresolved call inside") if not vis and g.has('methods'): print(" · visibility is not recorded in this graph, so exported surface cannot be told from internal") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 8fb7cab13..404481d61 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -585,7 +585,8 @@ class G: # upgraded plugin keeps answering from the edges the old code exported (the graph is unchanged, so `index` says it # is up to date and nothing rebuilds). 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the # bare mtime. - EXPORT_VERSION = '2' + # 3: a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites). + EXPORT_VERSION = '3' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" if os.path.exists(stamp) and open(stamp).read() == want: return @@ -593,7 +594,12 @@ class G: # `generated` is a client declaration too: a Lombok accessor, a record member, a C# auto-property. The engine # synthesises it and RESOLVES the call sites that name it, so filtering the edge out lost every call into a # generated member — which, on a project whose models are all @Data, is most of what touches the models. - edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')")] + # …except a call written inside a mocking library's stub or verification (`when(repo.find(1))`, + # `mock.Setup(r => r.Find(1))`): the receiver is a mock, so the real body never runs there, and walked as a call + # it made every test that stubs a method a `sound` route to it. impact lists those sites as "stubs it". + stubs = ax_edges.stub_sites(lambda s, p: self.q(s, *p)) if self.has('call_sites') else set() + edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')") + if r['call_site_id'] not in stubs] # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] @@ -621,7 +627,7 @@ class G: have = {(a, b) for a, b, _ in edges} edges += [(r[0], r[1], 'dispatch') for r in self.q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call") if (r[0], r[1]) not in have] self.write('edge', edges) - self.write('byname', [(r[0], r[1]) for r in self.q("SELECT DISTINCT s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index f831fe5d9..77817e7ed 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -433,6 +433,8 @@ def main(argv): i = passthru.index('--in'); scope = ['--in', passthru[i + 1]] tests = {} # test id -> record + stub_only, stub_by = {}, collections.defaultdict(set) # tests that only stub a body-edited declaration + fw_entries, fw_greps = {}, [] # entry points a framework enters: their tests are not counted unreadable, notes = [], [] pulled = collections.defaultdict(set) unseen = [] @@ -457,6 +459,23 @@ def main(argv): if rid and rid not in tests: tests[rid] = dict(rec, hops=None, certainty='inherited') pulled[rid].add(batch[0]) + # a test that only STUBS the declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) runs + # none of it: impact does not count it, and a body edit cannot fail it. A signature edit or a removal can (the + # stub no longer compiles, or stubs a method nothing calls), so there it is selected, labelled as what it is. + for rec in d.get('framework_entries') or []: + fw_entries.setdefault(rec.get('id') or rec.get('display'), rec) + if d.get('framework_grep') and d['framework_grep'] not in fw_greps: fw_greps.append(d['framework_grep']) + # a batch of several declarations breaks a stub when any of them is a signature change or a removal + breaks = any((by_target[t].get('kind') or '') in STUB_BREAKING for t in batch) + bt = batch[0] if len(batch) == 1 else f"{len(batch)} declarations" + for rec in d.get('stub_tests') or []: + rid = rec.get('id') + if not rid: continue + if breaks: + if rid not in tests: tests[rid] = dict(rec, hops=None) + pulled[rid].add(bt) + else: + stub_only.setdefault(rid, rec); stub_by[rid].add(bt) files = sorted({r.get('at', '').split(':')[0] for r in tests.values() if r.get('at')}) # A selected test carrying no location drops out of `files`, and `files` is what the command PRINTS and @@ -495,6 +514,8 @@ def main(argv): 'command': cmd_all, 'abstract_replaced': replaced, 'not_reached_from': unseen, + 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, + 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], 'unreadable_answers': [t for t, _ in unreadable], 'same_package_only': pkg_hits, 'same_name_only': name_hits.get('files') or [], @@ -586,6 +607,18 @@ def main(argv): print(f"\n {', '.join(list(progs)[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " "selection is a lower bound for it") + if fw_entries and not tests: + fe = list(fw_entries.values()) + print(f"\n NOT COUNTED: {len(fe)} entry point(s) of the change are entered by a framework (" + + ', '.join(f"{r.get('display')} {r.get('signal') or ''}".strip() for r in fe[:3]) + (f" … +{len(fe) - 3}" if len(fe) > 3 else '') + + ") — a test that drives them through it (a request, an event, a mediator send) is not selected unless a [by key]" + " route joins it, so the selection is a lower bound; to find those tests: " + ' ; '.join(fw_greps[:2])) + so = [r for i, r in stub_only.items() if i not in tests] + if so: + sf = collections.Counter((r.get('at') or '').split(':')[0] for r in so) + print(f"\n not selected: {len(so)} test(s) only stub a changed declaration on a mock (when/verify, Setup/Verify," + " Received/Returns) and run none of it, so a body edit cannot fail them; a signature change or a removal" + " selects them: " + ', '.join(f"{f} ({n})" for f, n in sf.most_common(4)) + (f" … +{len(sf) - 4}" if len(sf) > 4 else '')) if unlocated: print(f"\n {len(unlocated)} selected test(s) carry no location, so they are named here rather than" f" in the list above and cannot appear in the command:") @@ -615,6 +648,11 @@ def main(argv): return 0 +# the change kinds (axiomcode changed) that break a test which only stubs the declaration: its stub is written +# against the name and the parameters +STUB_BREAKING = {'signature', 'removed'} + + def _guess_lang(files): ext = collections.Counter(os.path.splitext(f)[1] for f in files) if not ext: return '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 26bc7ff15..970c8da10 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -331,10 +331,14 @@ route_site(f, l) :- registration(_, f, l, "route", _, _). // fast path reads the same one, so the rules and the fast path cannot disagree about a tier — which // is how the two copies of the binary test drifted apart from the rest of the vocabulary to begin with. .decl cert_tier(tier:symbol, cert:symbol, why:symbol) .input cert_tier -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +// A call written inside a mock's stub or verification (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): the exporter +// gives it the tier "stub" (ax_edges.stub_sites). It names the method, so a rename or a new parameter breaks it, and it +// runs nothing: the receiver is a mock. Its own row, whatever else the caller does; it is not an edge of the closure. +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, "stub", f, l), cert_tier("stub", cert, why). // a registration names the row: the edge is the engine's, the sentence is the registration's, and the // certainty stays the tier's — a route handed over as a value is still handed over as a value. -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). // A call INTO a method of a container-managed bean. Whether the container's proxy is on the path decides whether a // behavioural annotation added to the method (a transaction, a cache, an async dispatch, a retry, an authorization // check) reaches this caller at all: a call from inside the bean goes through `this` and never through the proxy. @@ -342,24 +346,25 @@ direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, .decl bean_call(q:symbol, c:symbol, m:symbol) // Not for an event either: the event system invokes a listener on the container's bean, so the proxy IS on that // path, and "an instance it obtained itself" would be false (#1391). It keeps its tier's own row above. -bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch". +bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch", t != "stub". // the type that the container hands the bean to: a field injection names the type, a constructor or setter names the method .decl receives_bean(t:symbol, into:symbol) receives_bean(t, into) :- injected(t, into, _), typ(into, _, _). receives_bean(t, into) :- injected(t, x, _), owner(x, into). direct(q, c, "uses", "calls it from inside the same bean — through this, not through the container proxy, so an added proxied annotation does NOT apply to this caller", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on the injected bean — the container proxy is on the path, so an added proxied annotation applies here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on an instance it obtained itself, not from the container — no proxy on the path, so an added proxied annotation does not apply here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l). // `!is_target_decl`: `this.rows.find(...)` inside `OrdersService.find` is the ARRAY's find, and with the receiver // untyped it matched the target by name — printing the declaration as a reader of itself. Worse, that row made the // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", !is_target_decl(q, c). +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 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 @@ -643,7 +648,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, _, _, _), !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", !seed(q, c). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the @@ -739,10 +744,33 @@ key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. key_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .output key_capped -.decl fw_edge(a:symbol, b:symbol, how:symbol) -fw_edge(a, b, "by key") :- literal(a, k, _, _), reg_key(b, _, k), a != b, !key_capped(k). +// THE HTTP METHOD, where both sides name one (ax_registration.route_verbs / literal_verbs): a test that POSTs "/orders" +// drives the handler that answers POST, not the one beside it that answers GET. A side that names none joins as before. +// The writer cap is counted per registration over the writers whose method fits it, so a path five tests write, one +// verb each, is not refused on behalf of a handler only two of them can reach; a key with no verb anywhere counts +// exactly as before. +// reg_verb(b, k, v) route k registered by b answers the HTTP method v +// lit_verb(a, k, v) a writes the path k as the argument of a request call for the method v +.decl reg_verb(b:symbol, k:symbol, v:symbol) .input reg_verb +.decl lit_verb(a:symbol, k:symbol, v:symbol) .input lit_verb +.decl key_join(a:symbol, b:symbol, k:symbol, w:symbol) +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b. // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered -fw_edge(a, b, "by key") :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b, !key_capped(r). +key_join(a, b, r, w) :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b. +.decl verb_fits(a:symbol, b:symbol, k:symbol, w:symbol) +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !reg_verb(b, k, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !lit_verb(a, w, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), lit_verb(a, w, v), reg_verb(b, k, v). +.decl reg_capped(k:symbol) +reg_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. +// the writers of the key as registered (an alias spelling is not counted, as before). A helper relation, not +// `count : { verb_fits(_, b, k, k) }`: Soufflé 2.5 counts an aggregate atom with a repeated variable as 1. +.decl exact_fit(a:symbol, b:symbol, k:symbol) +exact_fit(a, b, k) :- verb_fits(a, b, k, k). +.decl use_fan_for(b:symbol, k:symbol, n:number) +use_fan_for(b, k, n) :- reg_key(b, _, k), n = count : { exact_fit(_, b, k) }. +.decl fw_edge(a:symbol, b:symbol, how:symbol) +fw_edge(a, b, "by key") :- verb_fits(a, b, k, _), !reg_capped(k), use_fan_for(b, k, n), key_use_cap(c), n <= c. // a decorator that REBINDS THE NAME: `@audited def summarise` leaves `summarise` denoting the wrapper, so every // caller written with that name runs the wrapper. This one is the engine's own resolution, not a name match. fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b. @@ -897,6 +925,26 @@ test_near(q, m, d) :- test_hit(q, m, d, _), d = min x : test_hit(q, m, x, _). .output test_near .output test_hit +// ── a test that only STUBS the change: named, and never counted as running it ───────────────────────────────── +// `when(repo.find(1))`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(1))` name a method the closure reaches and run +// none of it. The tier "stub" keeps them out of `edge`, so they are not routes; this says which tests hold one, so a +// signature change still finds them. The callable that writes the stub (a test, a lambda in it, a setUp, a helper the +// setUp calls) is walked up through the same edges as the closure, and a test is found from it by the same three +// shapes a route ends in: the test itself, a fixture that runs before the tests of its type, a helper of that type. +.decl stub_at(q:symbol, x:symbol) +stub_at(q, x) :- reach(q, c, _), calls(x, c, "stub", _, _). +// A stub site the engine could not resolve (a Moq lambda whose parameter it did not type) is only a NAME: it leaves +// seed_byname, so it is no route, and it is not listed here either, since the same name is often another type's method. +.decl stub_near(q:symbol, a:symbol) +stub_near(q, x) :- stub_at(q, x). +stub_near(q, o) :- stub_at(q, x), lex_in(o, x). +stub_near(q, a) :- stub_near(q, b), edge(a, b, _). +.decl test_stub(q:symbol, m:symbol) +test_stub(q, m) :- stub_near(q, m), test_method(m). +test_stub(q, m) :- stub_near(q, fx), fixture(fx), !test_method(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f). +.output test_stub + // ── bound from outside the source ────────────────────────────────────────────────────────────────────────────── // A name written in a file the index does not read as source: an XML mapper, a META-INF/services file, a // properties or yaml file, a build script, a spec. The binding is made by name at run time, so the compiler is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 03ac38556..cc05d78ab 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -157,10 +157,15 @@ def _at(f_, l_): # (`callback_registered`) and a capped fan-out (`fan_capped`) as `resolved` — the strongest claim # the tool makes — while the rules, which read the same column, now do not (#1131). A caller with # several sites is named once, under the best of them. + # A call inside a mock's stub or verification is `stubs it`, and is not walked below: the same set the rules + # and the path export read (ax_edges.stub_sites). + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, p_).fetchall()) + con.execute("CREATE TEMP TABLE _stub(id TEXT PRIMARY KEY)") + con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in stubs]) _reads = collections.defaultdict(set) - for d, tier in q(f"""SELECT DISTINCT s.display, ce.tier FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id + for d, tier, sid in q(f"""SELECT DISTINCT s.display, ce.tier, ce.call_site_id FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id WHERE ce.callee_method_id IN ({ph})""", ids): - _reads[d].add(ax_edges.direct_cert(tier)) + _reads[d].add(ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier)) # …and the end a FRAMEWORK hands it over from (#1509): a task's .delay() producer, a signal's sender, a route # table, a Depends() default. The rules list it as a `framework` dependent, so this does too, or the hook # line and `impact` name different dependents for the same declaration. A direct row only, as in the rules: @@ -176,7 +181,7 @@ def _at(f_, l_): short = target.rsplit('.', 1)[-1] byname = sorted({r[0] for r in q( """SELECT DISTINCT s.display FROM call_sites cs JOIN unresolved_sites us ON us.call_site_id=cs.id - JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=?""", (short,))} - set(reads)) + JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) # the two counts. Each edge table joins in its OWN recursive branch so SQLite drives them by index; building # one combined edge CTE first scans all 608k edges per call (1.89 s against 0.02 s for the same answer). # THE DISPATCH HOP IS NARROWED, the same way the RULES narrow it. `edge.facts` is written by @@ -209,7 +214,8 @@ def _at(f_, l_): SELECT DISTINCT base_method_id b, candidate_method_id c FROM dispatch_candidates""") con.execute("CREATE INDEX _disp_c ON _disp(c)") rec = ("SELECT value, 0 FROM json_each(?)\n" - " UNION SELECT ce.caller_id, r.d+1 FROM call_edges ce JOIN r ON ce.callee_method_id=r.id WHERE r.d '' AND COALESCE(s.id, o.id) IS NOT NULL"""): if t != ot: continue recv.add(tgt if tgt in istype else type_of(tgt)) - # a bean call site, any tier but the set and an event: the event system invokes a listener on the container's + # a bean call site, any tier but the set, a stub and an event: the event system invokes a listener on the container's # bean, so the proxy IS on that path and "an instance it obtained itself" would be false (#1391) - callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch')} + callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch', ax_edges.STUB_TIER)} why = {} for c in callers: cot = type_of(c) @@ -742,6 +750,9 @@ def type_of(sid): return callers, why +STUB_BYNAME_WHY = 'stubs a method of this name on a mock (receiver not typed): the real method does not run there' + + def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): """direct(q,c,role,why,cert,f,l) for a method target — the rows the answer groups by *why* and *how sure*. @@ -757,15 +768,20 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): rows = [] # ordered by site line: when a caller has several call sites the answer names one of them, and the rules name # the lowest. Leaving the order to the table printed a different site (254 against 257) for the same caller. - sites = q(f"""SELECT e.caller_id, e.tier, s.file_path, s.start_line + # a site inside a mock's stub or verification carries the tier "stub", exactly as the rules' `calls` fact does + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + sites = [(c, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, t, f, l in q( + f"""SELECT e.call_site_id, e.caller_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_method_id IN ({ph}) AND e.callee_provenance='client' - ORDER BY s.start_line""", *ids) + ORDER BY s.start_line""", *ids)] bean_callers, why_of = _bean_call(q, ids, sites) routes = {(f, l): w for _d, f, l, k, _key, w in ax_registration.registrations(q, rel) if k == 'route'} for c, tier, f, l in sites: cert = ax_edges.direct_cert(tier) - if tier == 'multi_inferred': + if tier == ax_edges.STUB_TIER: + rows.append((c, 'uses', ax_edges.DIRECT_WHY[cert], cert, f or '', l or 0)) + elif tier == 'multi_inferred': # rule 208 carries no `!bean_call` guard, so a multi_inferred site stays `one of a set` even into a bean rows.append((c, 'uses', 'calls it', 'one of a set', f or '', l or 0)) elif c not in bean_callers: # `… , !bean_call(q, c, m)` @@ -778,15 +794,15 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # …and for a caller into a container-managed bean, EVERY site of it — the three bean rules end in a bare # `calls(c, m, _, f, l)` with no tier test, so a multi_inferred site of a bean caller is a row here too. for c, tier, f, l in sites: - if c in bean_callers: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) + if c in bean_callers and tier != ax_edges.STUB_TIER: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} seen = {r[0] for r in rows} for n in names: - for c, f, l, kind in q("""SELECT s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s + for sid, c, f, l, kind in q("""SELECT s.id, s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s JOIN unresolved_sites u ON u.call_site_id=s.id WHERE s.callee_name=?""", n): if kind in ('new', 'anon_new', 'CONSTRUCTOR_CALL'): continue # !ctor_kind(k) if c in ids: continue # !is_target_decl(q, c) - rows.append((c, 'uses', 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) + rows.append((c, 'uses', STUB_BYNAME_WHY if sid in stubs else 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) # the declaration handed over as a VALUE — a route registration, a callback — which has no call site at all # (the valueref / registered rules). The convention table is shared with the rules, in ax_registration.py, so # the two backends cannot disagree about what a registration is. @@ -1498,6 +1514,49 @@ class itself). The generated-member rules look here, so an accessor is found whi GEN_DECOR = set(GENERATED) # the names alone, where only membership matters +def stub_tests(q, depth, sets, stubs, rev=None): + """test_stub(q,m) (impact.dl): a test that stubs a reached callable on a mock, and so names it without running it. + The callable that writes the stub, and every callable lexically around it (a lambda's writer), is walked up the + same edges as the closure; a test is found from what that reaches as a route ends: the test itself, a fixture of + its type or a base type, or a helper of that type declared in a test file.""" + if not depth: return [] + tm, fx = sets + ids = list(depth); xs = set() + if stubs: + for k in range(0, len(ids), 400): + part = ids[k:k + 400] + for sid, c in q(f"SELECT call_site_id, caller_id FROM call_edges WHERE callee_method_id IN ({','.join('?' * len(part))})", *part): + if sid in stubs: xs.add(c) + if not xs: return [] + near = set(xs) + for x, f, a, b in q(f"SELECT id, file, line, end_line FROM symbols WHERE id IN ({','.join('?' * len(xs))})", *xs): + if not f: continue + near |= {r[0] for r in q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND id <> ?", f, a, b or a, x)} + frontier = list(near) + while frontier and rev is not None: + nxt = [] + for b in frontier: + for a, _t in rev.get(b, ()): + if a not in near: near.add(a); nxt.append(a) + frontier = nxt + info = {} + ph = ','.join('?' * len(near)) + for i, o, f in q(f"SELECT id, owner, file FROM symbols WHERE id IN ({ph})", *near): info[i] = (o, f) + tfiles = {r[0] for r in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + out = {m for m in near if m in tm} + owners = {info[x][0] for x in near if x in info and info[x][0] and x not in tm and (x in fx or info[x][1] in tfiles)} + if owners: + sub = set(owners) + if _has(q, 'type_ancestors'): + tid = {r[0]: r[1] for r in q("SELECT display, type_id FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + disp = {v: k for k, v in tid.items()} + want = {tid[o] for o in owners if o in tid} + sub |= {disp[r[0]] for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors") if r[1] in want and r[0] in disp} + for m, o in q("SELECT id, owner FROM symbols WHERE method_id IS NOT NULL AND owner IS NOT NULL"): + if m in tm and o in sub: out.add(m) + return sorted(out) + + def inherited_tests(q, hits): """`inherited_test(q,s,m,d) :- test_hit(q,m,d,_), owner(m,t), extends(s,t), typ(s,_,_), s != t` — the test classes that extend a class whose test was reached run that test too, in their own file.""" @@ -2544,9 +2603,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non # them. Without this the answer prints the machine's absolute path where the rules print src/main/java/… rel = site_file or (lambda x: x) out = {k: [] for k in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', - 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', + 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws')} - E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel) + E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) for qq in QS: # A query can carry SEVERAL target kinds at once: a name match that hits both a method and a field # resolves to both, and the rules simply union what each kind derives. Dispatch per kind and union here @@ -2581,7 +2640,8 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non seeds |= set(mids) | {c for c, _ in con} de += q(f"""SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IN ({mph}) AND callee_provenance='client'""", *mids) - byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w)} - seeds) + byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w) + and _w != STUB_BYNAME_WHY} - seeds) if 'type' in by_kind: tids = sorted(by_kind['type']) @@ -2797,6 +2857,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non hits = sorted(tests_reaching(q, depth, sets, every=True), key=lambda r: (r[0], r[1], r[2])) out['test_hit'] += [[m, str(d), via, qq] for m, d, via in hits] out['inherited_test'] += [[s_, m, str(d), qq] for s_, m, d in inherited_tests(q, hits)] + out['test_stub'] += [[m, qq] for m in stub_tests(q, depth, sets, stubs, rev=rev)] # gen_fired(q,d,why) — WHICH decoration or shape made the generated rules apply, so the answer can say # why it believes in members that have no declaration. # 143 type · 144 clinit · 145 field (through its owner) · 146 method (through its owner) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index e7da7e45b..9e6e516d9 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -58,6 +58,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 906e52d4c..074ac4321 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -95,3 +95,9 @@ the number in front of you. Skipping what it does not name is a decision about r you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear here (the `[text]` tier above recovers the ones whose test names the file it loads). +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. + diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index de376f493..cf2eb57e2 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -179,6 +179,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs new file mode 100644 index 000000000..731b67120 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs @@ -0,0 +1,77 @@ +using System.Threading.Tasks; +using Moq; +using NSubstitute; +using Shop; +using VerifyXunit; +using Xunit; + +namespace Shop.Tests; + +public class MoqStubTests +{ + private readonly Mock _prices = new Mock(); + + public MoqStubTests() + { + _prices.Setup(p => p.Price(1)).Returns(5); + } + + [Fact] + public void StubsInTheConstructor() + { + Assert.NotNull(_prices.Object); + } + + [Fact] + public void VerifiesTheStub() + { + _prices.Verify(p => p.Price(1)); + } +} + +public class SubstituteStubTests +{ + private readonly Prices _prices = Substitute.For(); + + [Fact] + public void StubsWithReturns() + { + _prices.Price(Ids.First()).Returns(4); + _prices.Received().Price(2); + } +} + +public class RealPriceTests +{ + [Fact] + public void RunsThePrice() + { + Assert.Equal(2, new Prices().Price(1)); + } + + [Fact] + public void TotalsWithTheRealPrice() + { + Assert.Equal(3, new Checkout(new Prices()).Total(1)); + } +} + +public class CheckoutWithMockTests +{ + private readonly Mock _prices = new Mock(); + + [Fact] + public void TotalsWithAMockedPrice() + { + Assert.Equal(1, new Checkout(_prices.Object).Total(1)); + } +} + +public class SnapshotTests +{ + [Fact] + public async Task VerifiesASnapshot() + { + await Verifier.Verify(new Prices().Price(3)); + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs new file mode 100644 index 000000000..924339947 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs @@ -0,0 +1,32 @@ +namespace Shop; + +public class Prices +{ + public virtual int Price(int id) + { + return id * 2; + } +} + +public class Ids +{ + public static int First() + { + return 1; + } +} + +public class Checkout +{ + private readonly Prices _prices; + + public Checkout(Prices prices) + { + _prices = prices; + } + + public int Total(int id) + { + return _prices.Price(id) + 1; + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..127aef5bc --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,61 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a call written in a Moq Setup/Verify lambda or on an NSubstitute Received()/Returns() runs none of the real method, in a test or in the test class's constructor: those tests are not counted, and the tests that call the real object, directly or through the class under test, are. A stub the engine resolved is listed apart; one in a lambda whose parameter it did not type is only a name, and is neither counted nor listed", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "tests: 3 of 7 test method(s)", + "RealPriceTests::RunsThePrice", + "RealPriceTests::TotalsWithTheRealPrice", + "[stubs it] 2 test(s) in 1 file(s), not counted", + "SubstituteStubTests::StubsWithReturns", + "CheckoutWithMockTests::TotalsWithAMockedPrice" + ], + "avoid": [ + "[fixture]", + "MoqStubTests::", + "[by name] 1 test", + "[by name] 2 test" + ] + }, + { + "why": "near miss: a snapshot library's Verify(value) takes no lambda, so the call that builds its argument runs and the test is counted", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "SnapshotTests::VerifiesASnapshot" + ] + }, + { + "why": "near miss: a call in the stubbed call's argument list runs for real to build the argument", + "run": [ + "impact", + "Ids.First", + "--tests" + ], + "want": [ + "tests: 1 of 7", + "SubstituteStubTests::StubsWithReturns" + ] + }, + { + "why": "the stubs stay dependents of the declaration, as stubs, so a rename still finds them", + "run": [ + "impact", + "Prices.Price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.Total" + ] + } + ] +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/case.json b/tests/cases/java/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..c5f7fecb2 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,90 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "a call written inside when(...), verify(x).m() or doReturn(v).when(x).m() on a mock runs none of the real method, and neither does a route through a type the test class holds as a @Mock: those tests are not counted, and a test that calls the real object, directly or through the class under test, is", + "run": [ + "impact", + "PriceService.price", + "--tests" + ], + "want": [ + "tests: 2 of 6 test method(s)", + "PriceServiceTest::runsThePrice", + "CheckoutTest::totalsWithTheRealPrice", + "[stubs it] 3 test(s) in 3 file(s), not counted", + "CheckoutStubTest::stubsThePrice", + "FixtureStubTest::usesTheStubbedPrice", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "a type they hold as a mock (PriceService)" + ], + "avoid": [ + "[sound] 3", + "[sound] 4", + "[fixture]" + ] + }, + { + "why": "near miss: the class under test is not itself mocked, so the mock-holding test still reaches it", + "run": [ + "impact", + "Checkout.total", + "--tests" + ], + "want": [ + "tests: 2 of 6", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "CheckoutTest::totalsWithTheRealPrice" + ] + }, + { + "why": "the stub stays a dependent of the declaration: a rename or a new parameter breaks it, so it is listed, as a stub and not a call", + "run": [ + "impact", + "PriceService.price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.total" + ] + }, + { + "why": "near miss: a call written in the stub's ARGUMENT list runs for real to build the argument, so the stubbing test reaches it", + "run": [ + "impact", + "Ids.first", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "CheckoutStubTest::stubsThePrice" + ] + }, + { + "why": "near miss: an HTTP test DSL's when() is a request step, not a stub, so a client call in its receiver chain is a route", + "run": [ + "impact", + "Payloads.make", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "RequestTest::postsAPayload" + ] + }, + { + "why": "a stub is not a hop of a chain either: the stubbing test has no path to the real method", + "run": [ + "path", + "CheckoutStubTest.stubsThePrice", + "PriceService.price" + ], + "expect_error": true, + "avoid": [ + "CheckoutStubTest.stubsThePrice \u2192 PriceService.price" + ], + "want": [] + } + ] +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java new file mode 100644 index 000000000..35af28e59 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java @@ -0,0 +1,13 @@ +package shop; + +public class Checkout { + private final PriceService prices; + + public Checkout(PriceService prices) { + this.prices = prices; + } + + public int total(int id) { + return prices.price(id) + 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java new file mode 100644 index 000000000..87cf69f8b --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java @@ -0,0 +1,19 @@ +package shop; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutStubTest { + @Mock private PriceService prices; + + @Test + public void stubsThePrice() { + when(prices.price(Ids.first())).thenReturn(5); + verify(prices).price(1); + doReturn(3).when(prices).price(2); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java new file mode 100644 index 000000000..4f09f55ec --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java @@ -0,0 +1,12 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class CheckoutTest { + @Test + public void totalsWithTheRealPrice() { + assertEquals(3, new Checkout(new PriceService()).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java new file mode 100644 index 000000000..194eec1a8 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java @@ -0,0 +1,15 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutWithMockTest { + @Mock private PriceService prices; + + @Test + public void totalsWithAMockedPrice() { + assertEquals(1, new Checkout(prices).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java new file mode 100644 index 000000000..52690baed --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java @@ -0,0 +1,28 @@ +package shop; + +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class FixtureStubTest { + @Mock private PriceService prices; + + @BeforeEach + public void setUp() { + stubPrices(); + } + + private void stubPrices() { + when(prices.price(7)).thenReturn(0); + } + + @Test + public void usesTheStubbedPrice() { + assertStubbed(); + } + + private void assertStubbed() { + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java new file mode 100644 index 000000000..125db5ddc --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java @@ -0,0 +1,7 @@ +package shop; + +public class Ids { + public static int first() { + return 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java new file mode 100644 index 000000000..c04c801ff --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java @@ -0,0 +1,7 @@ +package shop; + +public class Payloads { + public static String make() { + return "{}"; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java new file mode 100644 index 000000000..58731b080 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java @@ -0,0 +1,7 @@ +package shop; + +public class PriceService { + public int price(int id) { + return id * 2; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java new file mode 100644 index 000000000..942b28ca1 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java @@ -0,0 +1,13 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class PriceServiceTest { + @Test + public void runsThePrice() { + PriceService prices = new PriceService(); + assertEquals(2, prices.price(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java new file mode 100644 index 000000000..83621ebbf --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java @@ -0,0 +1,12 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class RequestTest { + @Test + public void postsAPayload() { + given().body(Payloads.make()).when().post("/orders").then().statusCode(200); + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/case.json b/tests/cases/java/route-under-type-prefix-and-verb/case.json new file mode 100644 index 000000000..c1fb67b9e --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/case.json @@ -0,0 +1,13 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a handler's route is its type's @RequestMapping prefix and its own mapping, and it is joined only to the requests that send its HTTP method: the three GETs of /orders drive list, the two POSTs do not", + "run": ["impact", "OrdersController.list", "--tests"], + "want": ["tests: 3 of 6 test method(s)", "OrdersListTest::listsOrders", "[by key]"], + "avoid": ["OrdersCreateTest"]}, + {"why": "near miss: the same path under the other verb reaches the other handler, and five tests writing one path are not refused as a key that identifies nothing, since each handler is written by fewer than the cap", + "run": ["impact", "OrdersController.create", "--tests"], + "want": ["tests: 2 of 6 test method(s)", "OrdersCreateTest::createsAnOrder", "OrdersCreateTest::rejectsAnEmptyOrder"], + "avoid": ["OrdersListTest", "OrdersCreateTest::addsALine"]}, + {"why": "a relative method path joins the type's prefix: @PostMapping(\"lines\") under /orders serves /orders/lines", + "run": ["impact", "OrdersController.addLine", "--tests"], + "want": ["tests: 1 of 6", "OrdersCreateTest::addsALine"]}]} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java new file mode 100644 index 000000000..758e470f3 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java @@ -0,0 +1,25 @@ +package shop; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/orders") +public class OrdersController { + @GetMapping + public String list() { + return "[]"; + } + + @PostMapping + public String create() { + return "{}"; + } + + @PostMapping("lines") + public String addLine() { + return "{}"; + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java new file mode 100644 index 000000000..0301211e1 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersCreateTest { + @Test + public void createsAnOrder() { + given().body("{}").when().post("/orders").then().statusCode(201); + } + + @Test + public void rejectsAnEmptyOrder() { + given().body("").when().post("/orders").then().statusCode(422); + } + + @Test + public void addsALine() { + given().body("{}").when().post("/orders/lines").then().statusCode(201); + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java new file mode 100644 index 000000000..8b2fc7762 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersListTest { + @Test + public void listsOrders() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersAgain() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersPaged() { + given().param("page", 2).when().get("/orders").then().statusCode(200); + } +} From 6b9b969036b65bf52cbe32c986bd7a458951a8d5 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:26:39 -0700 Subject: [PATCH 06/37] typescript: re-bless case 78 for the callback edges #1653 adds Case 78 (callable collections, #1657) was blessed on 0.1.5, before the rule that a function handed to a bodiless library method gets a callback_registered edge from the site that hands it over (#1653, on 0.1.6 only). With both on 0.1.6 the case gains three such edges and loses none: registry.push(pushed) module init -> pushed steps.forEach((step) => step(node)) runForEach -> its arrow frozen.reduce((acc, step) => ...) runReduce -> its arrow Each is the shape #1653 labels on purpose, and the JavaScript engine labels the same way. Only the .edges golden moves; the oracle, envelope and entry goldens are unchanged. --- graph/test/typescript/expected/78-callable-collections.edges | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graph/test/typescript/expected/78-callable-collections.edges b/graph/test/typescript/expected/78-callable-collections.edges index ee4407318..531f4a3d4 100644 --- a/graph/test/typescript/expected/78-callable-collections.edges +++ b/graph/test/typescript/expected/78-callable-collections.edges @@ -14,6 +14,9 @@ ambiguous_unknown METHOD_CALL pipeline#runForEach(Node) @L43 -> - ambiguous_unknown METHOD_CALL pipeline#runIndexed(Node,number) @L30 -> - ambiguous_unknown METHOD_CALL pipeline#runReduce(Node) @L46 -> - ambiguous_unknown METHOD_CALL pipeline#runValues(Node) @L64 -> - +callback_registered METHOD_CALL pipeline#() @L11 -> steps#pushed(Node) +callback_registered METHOD_CALL pipeline#runForEach(Node) @L43 -> pipeline#(?) +callback_registered METHOD_CALL pipeline#runReduce(Node) @L46 -> pipeline#(?,?) known_edge FUNCTION_CALL pipeline#runDirect(Node) @L82 -> steps#halve(Node) known_edge FUNCTION_CALL pipeline#runRegistry(Node) @L51 -> pipeline#(Node) known_edge FUNCTION_CALL steps#halve(Node) @L7 -> steps#measure(Node) From c1b60dd30bc4eab53cd2ee3eeff54df666976dc4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:06:31 -0700 Subject: [PATCH 07/37] mcp, cli: a list of sites comes one per line, path:line: code [tag] impact, path, context and test-impact take --grep (--grep-limit N): the verb runs with --json and ax_grep.py prints each site the way grep does, with the graph's certainty as a trailing tag, capped with the rest counted by kind. Tests fold to one row per test file (+N more in this file), so one file of many tests no longer spends the whole cap. The MCP tools answer this way by default; full=True, and parameters only the prose carries (source, why, delete, page), give the verb's own answer unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/mcp/server.py | 41 ++- plugins/axiomcode/skills/axiomcode/SKILL.md | 5 + .../skills/axiomcode/scripts/ax_fresh.py | 1 + .../skills/axiomcode/scripts/ax_grep.py | 288 ++++++++++++++++++ .../skills/axiomcode/scripts/axiomcode | 28 +- .../axiomcode/scripts/axiomcode-context | 8 +- skills/axiomcode/SKILL.md | 5 + .../cases/python/sites-one-per-line/case.json | 56 ++++ .../sites-one-per-line/shop/__init__.py | 0 .../python/sites-one-per-line/shop/cart.py | 9 + .../python/sites-one-per-line/shop/pricing.py | 6 + .../sites-one-per-line/tests/test_cart.py | 5 + .../sites-one-per-line/tests/test_checkout.py | 13 + .../typescript/sites-one-per-line/case.json | 21 ++ .../sites-one-per-line/src/cart.test.ts | 5 + .../typescript/sites-one-per-line/src/cart.ts | 9 + .../sites-one-per-line/src/pricing.ts | 7 + tests/mcp.py | 39 ++- 18 files changed, 519 insertions(+), 27 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py create mode 100644 tests/cases/python/sites-one-per-line/case.json create mode 100644 tests/cases/python/sites-one-per-line/shop/__init__.py create mode 100644 tests/cases/python/sites-one-per-line/shop/cart.py create mode 100644 tests/cases/python/sites-one-per-line/shop/pricing.py create mode 100644 tests/cases/python/sites-one-per-line/tests/test_cart.py create mode 100644 tests/cases/python/sites-one-per-line/tests/test_checkout.py create mode 100644 tests/cases/typescript/sites-one-per-line/case.json create mode 100644 tests/cases/typescript/sites-one-per-line/src/cart.test.ts create mode 100644 tests/cases/typescript/sites-one-per-line/src/cart.ts create mode 100644 tests/cases/typescript/sites-one-per-line/src/pricing.ts diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 7891e9991..80232e358 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -33,7 +33,9 @@ # a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(?]*>|'[^']*'|N(?:\|all)?(?![\w])|\d+(?![\w])|[a-z](?![\w.])|[\w*-]*[/.:*][^\s`'\"(),;\]]*))?") -_CODE = re.compile(r'^\s*(\d+ )?\| ') # a line of quoted source (context --source): never rewritten +# a line of quoted source (context --source), or a site of a grep-shaped answer (`path:line: code [tag]`, whose tag +# names no flag): never rewritten, since the code on it is the file's own text +_CODE = re.compile(r'^\s*(\d+ )?\| |^[^\s:]+:\d+: ') def mcp_words(text): """An answer with each CLI flag that is an MCP parameter written as that parameter: `--in ` -> `in_path=`, @@ -111,7 +113,7 @@ def run(args, cwd=None, timeout=900): # A QUERY ON A REPOSITORY WITH NO GRAPH YET does not hold the call for the length of a first build (minutes; past this # server's timeout it came back as a bare "Error executing tool"): the build is started in the background and the # answer says what stage it is at (ax_contract.ensure_graph). `index` is asked for a build and still waits for it. - env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1') if args and args[0] != 'index' else None + env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1', AXIOMCODE_SURFACE='mcp') if args and args[0] != 'index' else None try: r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) except OSError as e: @@ -132,6 +134,14 @@ def run(args, cwd=None, timeout=900): if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith('graph refresh:')) return mcp_words(out.strip()) or f"(no output, exit {r.returncode})" +# SITES, ONE PER LINE, BY DEFAULT. When the answer is a list of sites (who uses it, the hops of a chain, where a task +# lands, the tests to run) it comes the way grep prints: `path:line: code [resolved | one of a set | text | hop N]`, +# capped, the rest counted (scripts/ax_grep.py). The prose answer's sections, headers and explanations were most of what +# an agent read, and the fan-out it complained of. full=True, or asking for what only the prose carries (the code of a +# flow, test routes, a delete verdict, a later page), gives the verb's own answer, unchanged. +def grep(full, limit=0): + return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) + @srv.tool() def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str = '') -> str: """Build (or refresh) the call graph of a repository: parser → engine → /.axiomcode/out/graph.sqlite. Run once before path/impact/graph. lang: java|typescript|python|javascript|csharp when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" @@ -139,21 +149,24 @@ def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str return run(a) @srv.tool() -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" - a = ['context', task, repo] + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`); full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" + flow = source or explain or from_.strip() or (page and page != 1) or budget + a = ['context', task, repo] + grep(full or flow) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] return run(a) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date).""" - a = ['path', from_, to, repo] + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False, full: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain.""" + paged = page and page != 1 + a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) return run(a) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False) -> str: - """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date).""" - a = ['impact', *targets, repo] + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit else []) + (['--delete'] if delete else []) +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False) -> str: + """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too).""" + prose = full or why or delete or (page and page != 1) or budget + a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) return run(a) @srv.tool() @@ -163,9 +176,9 @@ def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', s return run(a) @srv.tool() -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1) -> str: - """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each.""" - a = ['test-impact', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1, full: bool = False) -> str: + """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`) and the command that runs them; full=True gives the prose.""" + a = ['test-impact', repo, *files] + grep(full or why or (page and page != 1)) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 6e827fa05..71b2e082c 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -13,6 +13,11 @@ Read / Grep results as `graph: …` lines. **Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` rows are leads, not facts. +**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without +`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, +surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. +From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. + ## Start here | the question in front of you | the call | diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 9790916f4..57a95bca6 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -673,6 +673,7 @@ def mark_answer(out, stale, as_json): def query_names(verb, args, repo): """the names a query asks about (impact's targets, path's endpoints, context's --from), as written""" names, i, pos = [], 0, [] + if '--' in args: args = args[args.index('--') + 1:] # --grep runs the verb under ax_grep.py … --: not names while i < len(args): a = args[i] if a in _FLAG_VALUE: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py new file mode 100644 index 000000000..f8607426d --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""ax_grep.py [--limit N] -- — an answer as sites, one per line, the way grep prints them. + + path:line: [what the graph knows about it] + +An agent reads grep output without effort: one hit per line, the location first, the code it will act on after it. The +verbs' prose answers carry the same sites inside sections, headers and explanations, and when the answer is a LIST OF +SITES (who calls this, what reaches that, the hops of a chain, the tests to run) that prose is mostly the fan-out an +agent has to wade through. So the verb is run with --json, the same answer as a document, and its sites are printed: + + impact must-change-with-it, then direct dependents surest first, then what reaches them by hop, then the tests; + an `alongside` row (no call, no reference) and a text mention written as prose are counted, never listed + path every hop of each chain at the line the call is written on; `path '*' X` the declarations that reach X + context the call flow's steps (or, with no flow, the entry points and the declarations named per file), then the + text files that name them; in a repository in several languages, the graphs where the task's words land + test-impact the tests, nearest first, and the command that runs them + +The tag is the graph's knowledge, in its own words: [resolved] · [one of a set] · [registered] · [by name] · [text] · +hop N · test. The first --limit (default 30) sites are printed and the rest counted by kind; the last lines say whether +the answer was verified and what it cannot see. The verbose answer is the verb without --grep (MCP full=True). +A verb that refuses (no such name, no graph), or answers with no site at all (no chain, no dependent), is printed +exactly as the verb said it: there the reason is the answer. +""" +import json, os, re, subprocess, sys + +H = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, H) +import ax_edges + +CAP = 30 +TEXT_ROWS = 5 # rows of a name written in a non-source file: leads, so a few and a count +CODE_WIDTH = 140 +# a certainty word as the answers print it, keyed by the word the --json document carries +TAG = {'sound': 'resolved', 'entry': 'entry', None: 'resolved'} + + +class Code: + """the text of a line of a file under the repository, read once per file""" + def __init__(self, repo): + self.repo, self.files = repo, {} + + def line(self, at): + f, _, n = (at or '').rpartition(':') + if not f or not n.isdigit(): return None + if f not in self.files: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as h: self.files[f] = h.read().split('\n') + except OSError: self.files[f] = None + L = self.files[f]; i = int(n) + if not L or not 0 < i <= len(L): return None + s = L[i - 1].strip() + return s if len(s) <= CODE_WIDTH else s[:CODE_WIDTH - 1] + '…' + + +def site(code, at, tag, name=''): + """one row: path:line: code [tag]. With no readable line the declaration's name stands in for the code.""" + text = code.line(at) + if text is None: text = name or '' + # (not a `` / `` / ``: the line itself is all there is to say of those) + elif name and '<' not in name and not re.search(r'\b' + re.escape(name.split('.')[-1].split('(')[0]) + r'\b', text): + tag = f"{tag} · {name}" if tag else name # the line does not show whose it is (a decorator, a wrapped call) + return f"{at}: {text} [{tag}]" if tag else f"{at}: {text}" + + +def stale(r): + return ' · may be out of date' if isinstance(r, dict) and r.get('stale') else '' + + +def n_sites(r): + n = r.get('sites') or 1 + return f" ×{n}" if n > 1 else '' + + +def per_test_file(tests): + """the tests one row per test FILE, its nearest test first, with how many more that file holds. A test file is what + gets run, and a route through a module's import reaches every test in it: listed one by one, the 30 tests of one + file were the whole capped answer and pushed out everything the change reaches""" + first, n = {}, {} + for t in tests: + f = (t.get('at') or '').rpartition(':')[0] + n[f] = n.get(f, 0) + 1 + first.setdefault(f, t) + return [(t, n[f] - 1) for f, t in first.items()] + + +def more_in_file(k): + return f" · +{k} more in this file" if k else '' + + +# ── impact ─────────────────────────────────────────────────────────────────────────────────────────────────────────── +def impact(d, code): + rows, rest = [], {} + def more(k, n=1): rest[k] = rest.get(k, 0) + n + for r in d.get('contract', []): + rows.append(('contract', site(code, r['at'], f"must change · {r['why']}{stale(r)}", r['display']))) + seen = set() + for r in d.get('direct', []): + cert = r.get('certainty') or 'resolved' + if cert == 'alongside': more('alongside (no call, no reference)'); continue + seen.add(r['id']) + rows.append((cert, site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']))) + tests = {t['id'] for t in d.get('tests', [])} + # a module's top level reaches it too, but its row is the file's first line (an import), which says nothing: those + # come after the tests, so a cap spends its lines on callables and on what to run + modules = [] + for r in d.get('reached', []): + if r['id'] in seen or r['id'] in tests: continue + mod = '' in (r.get('display') or '') + row = ('module' if mod else 'reached', site(code, r['at'], f"hop {r['hops']}" + (' · module scope' if mod else '') + + (' · test' if r.get('test') else '') + stale(r), r['display'])) + (modules if mod else rows).append(row) + for r, k in per_test_file(d.get('tests', [])): + rows.append(('test', site(code, r['at'], f"test · {TAG.get(r.get('certainty'), r.get('certainty'))} · hop {r['hops']}" + more_in_file(k) + stale(r), r['display']))) + for r in d.get('stub_tests', []): + rows.append(('stub', site(code, r['at'], f"test · stubs it{stale(r)}", r['display']))) + # a test file's top level is already its test row above + listed = {(r.get('at') or '').rpartition(':')[0] for r in d.get('tests', [])} + rows += [m for m in modules if m[1].split(':', 1)[0] not in listed] + # a name written in a non-source file is a lead, not a dependent: a few are listed, the rest counted. A common name + # (`main`, `run`) is written in hundreds of CI files and fixtures, and listed in full they were the whole answer + shown = 0 + for r in d.get('external', []): + if r.get('prose'): more('plain-word mentions'); continue + if shown >= TEXT_ROWS: more('[text]'); continue + rows.append(('text', site(code, r['at'], f"text · {r.get('how') or 'names it'}{stale(r)}"))); shown += 1 + foot = [] + nt = len(d.get('tests', [])) + if 'test_universe' in d: foot.append(f"tests: {nt} of {d['test_universe']} reach it" + ("; `axiomcode test-impact` runs them" if nt else '')) + foot.append(verified(d.get('verified'), d.get('checked_hops'))) + if d.get('unresolved_inside'): foot.append(f"bound: {d['unresolved_inside']} unresolved call(s) inside — a lower bound") + return rows, rest, foot + + +# ── path ───────────────────────────────────────────────────────────────────────────────────────────────────────────── +def path(d, code): + rows = [] + for a in d.get('answers', []): + hops, prev = a.get('hops', []), a.get('from', '') + for i, h in enumerate(hops, 1): + t = h.get('tier') + cert = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(t) + at = h.get('call_at') or h.get('declared_at') + # caller → callee on every hop: the line is in the caller, and a chain found from B back to A reads right + rows.append(('hop', site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"))) + prev = h['to'] + for r in d.get('reached', []): + rows.append(('reached', site(code, r['at'], f"hop {r['hops']}{stale(r)}", r['name']))) + # every printed hop of a chain is looked up again (`unverified_hops` counts the ones that were not there); the + # document's own `verified` speaks for the `'*'` closure + ans = d.get('answers', []) + v = d.get('verified') + if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False + foot = [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] + if d.get('bound'): foot.append(f"bound: {d['bound']}") + return rows, {}, foot + + +# ── context ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def context(d, code): + rows = [] + listed = set() + for s in d.get('flow', []): + if s.get('repeat_of'): continue + c = s.get('certainty') or 'entry' + tag = f"step {s['step']} · {'entry' if c == 'entry' else TAG.get(c, c)}" + if s.get('called_at_line'): tag += f" · called at L{s['called_at_line']}" + if s.get('unresolved'): tag += ' · ⚠ ' + ', '.join(s['unresolved'][:2]) + rows.append(('flow', site(code, s['at'], tag + stale(s), s['name']))); listed.add(s['at']) + if not d.get('flow'): + for e in d.get('entry_points', []): + if e['at'] in listed: continue + rows.append(('entry', site(code, e['at'], f"entry · {e.get('why', '')}".rstrip(' ·') + stale(e), e['name']))); listed.add(e['at']) + for f in d.get('files', []): + for nm, at in zip(f.get('declarations', []), f.get('declared_at') or []): + if not at or at in listed: continue + rows.append(('file', site(code, at, f"{f['how']} · {f.get('task_terms_matched', 0)} task term(s){stale(f)}", nm))); listed.add(at) + tb = d.get('text_bindings', []) + for t in tb[:TEXT_ROWS]: + rows.append(('text', site(code, f"{t['file']}:{t['line']}", 'text · names ' + (t.get('name') or ', '.join(t.get('terms', []))) + stale(t)))) + foot = [] + if d.get('not_indexed'): foot.append('not indexed: ' + ', '.join(map(str, d['not_indexed'][:3]))) + foot.append("bound: follows call edges and names; a constant, config key, string or reflection does not appear") + return rows, ({'[text]': len(tb) - TEXT_ROWS} if len(tb) > TEXT_ROWS else {}), foot + + +def landing(d): + """how much of the task a language's graph answers for: the most task words one of its files or entry points matches""" + best = max([f.get('task_terms_matched', 0) for f in d.get('files', [])] + [0]) + for e in d.get('entry_points', []): + best = max(best, len(re.findall(r"'[^']+'", e.get('why', '')))) + return best + + +# ── test-impact ────────────────────────────────────────────────────────────────────────────────────────────────────── +def test_impact(d, code): + rows = [] + for f in d.get('edited_test_files', []): + rows.append(('test', f"{f}:1: (edited test file) [test · edited]")) + for t, k in per_test_file(d.get('tests', [])): + rows.append(('test', site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']))) + # a changed file no graph follows (a script, a fixture) is run by the test files that name it in their text + names = {} + for f, v in (d.get('named_in_test_text') or {}).items(): + for t in (v or {}).get('tests', []): + names.setdefault(t, []).append((v or {}).get('needle') or f) + for t, ns in names.items(): + rows.append(('text test', f"{t}:1: (names {', '.join(dict.fromkeys(ns))}) [test · text]")) + foot = [] + if d.get('command'): foot.append(f"run: {d['command']}") + if d.get('bound'): foot.append(f"bound: {d['bound']}") + return rows, {}, foot + + +def verified(v, hops=None): + if v is True: return "verified: ✓" + (f" ({hops} edge(s) looked up again)" if hops else '') + if v is False: return "verified: ✗ — an edge in this answer is not in the graph; do not use it" + return "verified: nothing to check" + + +# how the rows left out past --grep-limit are counted, by the kind each verb gave them +KIND_WORD = {'contract': 'must change', 'reached': 'hop N', 'module': 'module scope', 'stub': 'stubs it', 'flow': 'step', 'file': 'declaration', + 'test': 'test file'} +VERBS = {'impact': impact, 'path': path, 'context': context, 'test-impact': test_impact, 'tests': test_impact} + + +def render(verb, doc, repo, cap=CAP): + """the lines of the grep-shaped answer for a verb's --json document (and every other language's in it)""" + code = Code(repo) + docs = [(doc.get('language') or '', doc)] + list((doc.get('other_languages') or {}).items()) + left_out = [] + if verb == 'context' and len(docs) > 1: + # a task is about one language's code far more often than about all of them: the graphs its words land in + # best come first, and one where half as many words land or fewer is named, not listed + docs.sort(key=lambda ld: -landing(ld[1])) + best = landing(docs[0][1]) + if best > 0: + left_out = [l for l, x in docs if 2 * landing(x) <= best] + docs = [(l, x) for l, x in docs if 2 * landing(x) > best] + rows, rest, foot = [], {}, [] + for lang, d in docs: + if not isinstance(d, dict): continue + r, m, f = VERBS[verb](d, code) + rows += r + for k, n in m.items(): rest[k] = rest.get(k, 0) + n + foot += [x for x in f if x not in foot] + out = [x for _k, x in rows[:cap]] + if len(rows) > cap: + kinds = {} + for k, _x in rows[cap:]: + k = KIND_WORD.get(k, k); kinds[k] = kinds.get(k, 0) + 1 + rest = dict(list({f'[{k}]': n for k, n in sorted(kinds.items(), key=lambda kv: -kv[1])}.items()) + list(rest.items())) + if rest: + how = ("limit=N lists more, full=True gives the whole answer" if os.environ.get('AXIOMCODE_SURFACE') == 'mcp' else + "--grep-limit N lists more, the verb without --grep gives the whole answer") + out.append(f"… +{sum(rest.values())} more not listed: " + ', '.join(f"{n} {k}" for k, n in rest.items()) + f" — {how}") + if not rows: return None + if left_out: out.append(f"also answered in the {', '.join(left_out)} graph(s), where fewer of the task's words land; --lang asks one") + return out + foot + + +def main(argv): + verb, repo = argv[0], argv[1] + cap = CAP + rest = argv[2:] + if rest and rest[0] == '--limit': cap = int(rest[1]); rest = rest[2:] + cmd = rest[1:] if rest and rest[0] == '--' else rest + import ax_exec + r = subprocess.run(ax_exec.program(cmd + ['--json']), stdout=subprocess.PIPE, text=True, encoding='utf-8', errors='replace') + try: doc = json.loads(r.stdout) + except ValueError: doc = None + if not isinstance(doc, dict) or r.returncode: + # a refusal, as the verb said it: its prose when the document carries it ("no chain connects…"), else its text + if isinstance(doc, dict) and isinstance(doc.get('prose'), list): print('\n'.join(doc['prose'])) + else: sys.stdout.write(r.stdout) + return r.returncode + lines = render(verb, doc, repo, cap) + if lines is None: + # no site at all ("no chain", nothing depends on it): the WHY is the answer, and only the prose says it + if isinstance(doc.get('prose'), list): print('\n'.join(doc['prose'])) + else: return subprocess.run(ax_exec.program(cmd)).returncode + return r.returncode + print('\n'.join(lines)) + return r.returncode + + +if __name__ == '__main__': + if len(sys.argv) < 4: sys.exit(__doc__) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 5e83b3468..d753d02ed 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -3,7 +3,7 @@ # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. -# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] +# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] [--grep] # THE FRONT DOOR: the files and callables a task touches, from the problem statement — when there is no name # to ask about yet. Deterministic: task terms scored against the graph's vocabulary by inverse document # frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. @@ -16,7 +16,7 @@ # and --library it was indexed with, never for a language the index left out; indexed first when there is none. # Prints what it drew and the page's absolute path: /.axiomcode/graph/graph.html, or --out (a folder gets # /.html, a .html path is used as given). The page embeds the sources, so a node opens its code. -# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] [--grep] # the shortest chain of calls from A to B per reached target, and with --every all the routes; through what? # '*' as one endpoint: path '*' X = everything that can reach X, with the entry points among them; path X '*' = everything X reaches. Endpoints exactly as written in the code: Owner.method, # method (free function or any owner), Type (all its methods), file.ts:123, file.py. Datalog over the graph; every @@ -24,7 +24,7 @@ # Every hop carries the LINE THE CALL IS WRITTEN ON, how certain the edge is, and what kind of call it is — # an invocation, a construction, a constructor chain, a super call, a decorator, a property access, a method # reference — in one vocabulary across all five languages. A hop that is not a call is marked and not counted. -# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] [--grep] # what has to be looked at again when a declaration changes: a method, a field / constant / enum member, a type, a # parameter (Owner.m(p)), a type parameter (Type), a local (Owner.m:v), Type. / Type.. Prints what # must change with it (overrides, subtypes), what directly touches it with WHY and how sure ([resolved] / [in scope] / @@ -36,7 +36,7 @@ # name, initializer; type header; body only; removed; added) — the working tree against the commit the graph was built # from, or a branch's commits (--range a..b reads from `git merge-base a b`) — each with the target `impact` takes; # --impact runs impact on all of them as one change set. Without git, name the files. -# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--grep] # which tests actually have to run for this edit: the test files that reach any changed declaration, with the # chain, so a selection can be checked rather than trusted. Conservative by design — a test reached only # through an edge the graph does not encode (reflection, a service loader) will NOT appear. @@ -50,6 +50,9 @@ # language's, the one with the most files, first). --lang restricts it to one language or a comma list, the first the main one; # --src limits the analysed tree (e.g. src); --library names dependency roots. --fresh (context, path, impact) waits for # a refresh in flight and answers from the new graph; without it an answer from a graph older than an edit marks its rows. +# --grep (context, path, impact, test-impact) prints the answer's sites one per line, as grep does: `path:line: [resolved | one of a set | by name | text | hop N | test]`, the first 30 (--grep-limit N) and a count of +# the rest, then whether it was verified and its bound. The MCP tools answer this way by default; full=True is the prose. case "$0" in */*) H="${0%/*}";; *) H=.;; esac; H="$(cd "${H:-/}" && pwd)" # no `dirname`: see bin/axiomcode # PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not @@ -101,6 +104,8 @@ while [ $# -gt 0 ]; do --src) export AXIOMCODE_SRC="$2"; shift 2 ;; --library) export AXIOMCODE_LIBRARY="$2"; shift 2 ;; --fresh) export AXIOMCODE_FRESH=1; shift ;; + --grep) GREP=1; shift ;; + --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; *) ARGS+=("$1"); shift ;; esac done @@ -128,6 +133,11 @@ FR=.; skip=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in --in|--from|--budget|--seeds|--depth|--limit|--tests-in|--kind|--range|--old|--new|--file|--page|--page-budget|--out) skip=1; continue ;; esac [ -d "$a" ] && FR="$a" done +# --grep (context, path, impact, test-impact): the answer's sites one per line, `path:line: code [tag]`, the way grep +# prints them (ax_grep.py). The verb answers with --json and the sites are printed from that document, so the two +# shapes are one answer. Without it the answer is the verb's own, unchanged. +G=() +case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac # A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query # asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in # AXIOMCODE_GRAPH was chosen by the caller and is asked alone. @@ -135,17 +145,17 @@ Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_ # (-L too: a pointer whose target is gone is a graph to repair — the refresher does it — not a repository without one) if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sqlite" ]; then case "$cmd" in - context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-$cmd" ${ARGS[@]+"${ARGS[@]}"} ;; + context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-$cmd" ${ARGS[@]+"${ARGS[@]}"} ;; changed|test-impact|tests) python3 "$H/ax_fresh.py" baseline "$FR" "${AXIOMCODE_BASELINE_WAIT:-30}" || true ;; esac fi case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; - context) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; - path) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; - impact) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + context) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + path) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; + impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; - test-impact|tests) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; graph) exec python3 "$H/axiomcode-graph" ${ARGS[@]+"${ARGS[@]}"} ;; install) exec python3 "$H/axiomcode-install" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # what this dispatcher answers to; bin/axiomcode asks, so the two cannot drift diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index e090f3d1d..dfe7cb769 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1129,10 +1129,12 @@ def main(argv): if nm not in cur or sc > cur[nm][0]: cur[nm] = (sc, len(matched), nm) if sy.get('line'): span_of[(f, nm)] = (sy['line'], sy.get('end_line') or sy['line']) + def decls_of(f): + return [nm for _sc, _m, nm in sorted(best_in_file.get(f, {}).values(), key=lambda x: (-x[0], -x[1], x[2]))[:DECLS_PER_FILE]] + # `declared_at` beside the names: the line each one starts on, so a site list (ax_grep.py) can point at it RESULT['files'] = [{'file': f, 'how': ('name only, no call path' if d == 99 else f'hop {d}'), - 'symbols': n, 'task_terms_matched': hit, - 'declarations': [nm for _sc, _m, nm in - sorted(best_in_file.get(f, {}).values(), key=lambda x: (-x[0], -x[1], x[2]))[:DECLS_PER_FILE]]} + 'symbols': n, 'task_terms_matched': hit, 'declarations': decls_of(f), + 'declared_at': [f"{f}:{span_of[(f, nm)][0]}" if (f, nm) in span_of else None for nm in decls_of(f)]} for f, (d, hit, n) in shown] for f, (d, hit, n) in shown: mark = f" [{hit} task term(s)]" if hit else "" diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9e6e516d9..77f053dd6 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -13,6 +13,11 @@ Read / Grep results as `graph: …` lines. **Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` rows are leads, not facts. +**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without +`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, +surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. +From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. + ## Start here | the question in front of you | the call | diff --git a/tests/cases/python/sites-one-per-line/case.json b/tests/cases/python/sites-one-per-line/case.json new file mode 100644 index 000000000..4dd3bde0c --- /dev/null +++ b/tests/cases/python/sites-one-per-line/case.json @@ -0,0 +1,56 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "impact --grep: one site per line, path:line: the code on it, and what the graph knows as a trailing tag, surest first, then what reaches it by hop, then the tests; no section headers", + "run": ["impact", "total", "--grep"], + "want": ["shop/cart.py:5: return total(items) [resolved · checkout]", + "shop/cart.py:8: def receipt(items): [hop 2]", + "tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]", + "verified: ✓"], + "avoid": ["reads or uses it (", "change: total", "entry points ("]}, + {"why": "--grep-limit caps the sites and counts the rest by kind (this case.json names `total`: those are the [text] rows)", + "run": ["impact", "total", "--grep", "--grep-limit", "1"], + "want": ["shop/cart.py:5: return total(items) [resolved · checkout]", "9 [text], 2 [test file], 1 [hop N]"], + "avoid": ["tests/test_cart.py:4:"]}, + {"why": "a name written in non-source files is a lead: at most five such rows, the rest counted, and all after the code sites", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]\ncase.json:"], + "avoid": ["[text · names the method]\nshop/"]}, + {"why": "tests come one row per test file, its nearest test first and the rest of that file counted: a file of many tests is what gets run, and listed one by one they spent the whole cap", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_checkout.py:4: def test_empty(): [test · resolved · hop 2 · +2 more in this file]"], + "avoid": ["tests/test_checkout.py:8:", "tests/test_checkout.py:12:"]}, + {"why": "control: a test file with one test is its row, with no count", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]\n"], + "avoid": ["test_receipt(): [test · resolved · hop 3 · +"]}, + {"why": "test-impact --grep folds the same way (the edit named by file: every test that reaches shop/pricing.py)", + "run": ["test-impact", "{repo}", "shop/pricing.py", "--grep"], + "want": ["tests/test_checkout.py:4: def test_empty(): [test · resolved · hop 2 · +2 more in this file]", + "tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]"], + "avoid": ["tests/test_checkout.py:8:"]}, + {"why": "control: without --grep the answer is the verb's own, sections and all, unchanged", + "run": ["impact", "total"], + "want": ["reads or uses it (1 callable(s): 1 resolved):", "[resolved] checkout shop/cart.py:5 — calls it"], + "avoid": ["shop/cart.py:5: return total(items)"]}, + {"why": "control: --json is still one document, untouched by the grep shape", + "run": ["impact", "total", "--json"], + "want": ["\"direct\""], + "stdout_json": true}, + {"why": "path --grep: each hop at the line its call is written on, caller → callee, and the hops looked up again", + "run": ["path", "receipt", "total", "--grep"], + "want": ["shop/cart.py:9: return \"paid \" + str(checkout(items)) [resolved · hop 1/2 receipt → checkout]", + "shop/cart.py:5: return total(items) [resolved · hop 2/2 checkout → total]", + "verified: ✓ (2 edge(s) looked up again)"]}, + {"why": "path --grep with no chain: the reason is the answer, said as the verb says it, never a JSON document", + "run": ["path", "unused", "checkout", "--grep"], + "want": ["no chain of resolved calls connects unused and checkout"], + "avoid": ["\"answers\""], + "expect_error": true}, + {"why": "context --grep: the declarations the task lands on, one per line", + "run": ["context", "the total of a checkout", "--grep"], + "want": ["shop/pricing.py:1: def total(items): [entry · matches 'total']", "shop/cart.py:4: def checkout(items): [entry"], + "avoid": ["where this task's words land"]}, + {"why": "a name that is not there is refused as the verb refuses it", + "run": ["impact", "nosuch", "--grep"], + "want": ["nothing named 'nosuch'"], + "expect_error": true}]} diff --git a/tests/cases/python/sites-one-per-line/shop/__init__.py b/tests/cases/python/sites-one-per-line/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/sites-one-per-line/shop/cart.py b/tests/cases/python/sites-one-per-line/shop/cart.py new file mode 100644 index 000000000..588208e9a --- /dev/null +++ b/tests/cases/python/sites-one-per-line/shop/cart.py @@ -0,0 +1,9 @@ +from shop.pricing import total + + +def checkout(items): + return total(items) + + +def receipt(items): + return "paid " + str(checkout(items)) diff --git a/tests/cases/python/sites-one-per-line/shop/pricing.py b/tests/cases/python/sites-one-per-line/shop/pricing.py new file mode 100644 index 000000000..198eb7011 --- /dev/null +++ b/tests/cases/python/sites-one-per-line/shop/pricing.py @@ -0,0 +1,6 @@ +def total(items): + return sum(items) + + +def unused(): + return 0 diff --git a/tests/cases/python/sites-one-per-line/tests/test_cart.py b/tests/cases/python/sites-one-per-line/tests/test_cart.py new file mode 100644 index 000000000..da11135bb --- /dev/null +++ b/tests/cases/python/sites-one-per-line/tests/test_cart.py @@ -0,0 +1,5 @@ +from shop.cart import receipt + + +def test_receipt(): + assert receipt([1, 2]) == "paid 3" diff --git a/tests/cases/python/sites-one-per-line/tests/test_checkout.py b/tests/cases/python/sites-one-per-line/tests/test_checkout.py new file mode 100644 index 000000000..86809c1b1 --- /dev/null +++ b/tests/cases/python/sites-one-per-line/tests/test_checkout.py @@ -0,0 +1,13 @@ +from shop.cart import checkout + + +def test_empty(): + assert checkout([]) == 0 + + +def test_one(): + assert checkout([2]) == 2 + + +def test_two(): + assert checkout([1, 2]) == 3 diff --git a/tests/cases/typescript/sites-one-per-line/case.json b/tests/cases/typescript/sites-one-per-line/case.json new file mode 100644 index 000000000..dc87d7361 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/case.json @@ -0,0 +1,21 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "impact --grep: one site per line with the code on it and a trailing tag; a test that is an anonymous callback is its line, with no name tacked on", + "run": ["impact", "total", "--grep"], + "want": ["src/cart.ts:4: return total(items); [resolved · checkout]", + "src/cart.ts:7: export function receipt(items: number[]): string { [hop 2]", + "src/cart.test.ts:3: it('prints a receipt', () => { [test · resolved · hop 3]", + "verified: ✓"], + "avoid": ["reads or uses it (", "]", "]"]}, + {"why": "control: without --grep the answer is the verb's own, unchanged", + "run": ["impact", "total"], + "want": ["reads or uses it (1 callable(s): 1 resolved):"], + "avoid": ["src/cart.ts:4: return total(items);"]}, + {"why": "path --grep: each hop at its call line, caller → callee", + "run": ["path", "receipt", "total", "--grep"], + "want": ["src/cart.ts:8: return 'paid ' + String(checkout(items)); [resolved · hop 1/2 receipt → checkout]", + "src/cart.ts:4: return total(items); [resolved · hop 2/2 checkout → total]"]}, + {"why": "context --grep: the declarations the task lands on", + "run": ["context", "the total of a checkout", "--grep"], + "want": ["src/pricing.ts:1: export function total(items: number[]): number { [entry · matches 'total']"], + "avoid": ["where this task's words land"]}]} diff --git a/tests/cases/typescript/sites-one-per-line/src/cart.test.ts b/tests/cases/typescript/sites-one-per-line/src/cart.test.ts new file mode 100644 index 000000000..8666f62a2 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/cart.test.ts @@ -0,0 +1,5 @@ +import { receipt } from './cart'; + +it('prints a receipt', () => { + expect(receipt([1, 2])).toBe('paid 3'); +}); diff --git a/tests/cases/typescript/sites-one-per-line/src/cart.ts b/tests/cases/typescript/sites-one-per-line/src/cart.ts new file mode 100644 index 000000000..47989b37b --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/cart.ts @@ -0,0 +1,9 @@ +import { total } from './pricing'; + +export function checkout(items: number[]): number { + return total(items); +} + +export function receipt(items: number[]): string { + return 'paid ' + String(checkout(items)); +} diff --git a/tests/cases/typescript/sites-one-per-line/src/pricing.ts b/tests/cases/typescript/sites-one-per-line/src/pricing.ts new file mode 100644 index 000000000..d63879308 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/pricing.ts @@ -0,0 +1,7 @@ +export function total(items: number[]): number { + return items.reduce((a, b) => a + b, 0); +} + +export function unused(): number { + return 0; +} diff --git a/tests/mcp.py b/tests/mcp.py index e16606841..d823c2a8e 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -162,11 +162,47 @@ def check_words(): ("print it with --json", "print it with --json"), (" 49 | args = ['--in', path, '--tests-only']", " 49 | args = ['--in', path, '--tests-only']"), (" | … +23 more line(s) --limit", " | … +23 more line(s) --limit"), - ("a pre-built --lang java graph", "a pre-built --lang java graph")] + ("a pre-built --lang java graph", "a pre-built --lang java graph"), + # a site of a grep-shaped answer is the file's own text: a flag written in that code stays as written + ("tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]", + "tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]"), + # and the footer under the sites is prose, rewritten as ever + ("… +3 more not listed: 3 [text] — pass --in to narrow", "… +3 more not listed: 3 [text] — pass in_path= to narrow")] return [f"mcp_words({src!r}) gave {server.mcp_words(src)!r}, want {want!r}" for src, want in cases if server.mcp_words(src) != want] +def check_grep_default(): + """A list of sites comes one per line (--grep) by default; full=True, and every parameter asking for what only the + prose carries (a flow's code, test routes, a delete verdict, a later page), gives the verb's own answer.""" + sys.path.insert(0, os.path.dirname(SERVER)) + import server + seen = [] + real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or '') + try: + grep = [lambda: server.axiomcode_impact(['A.f']), lambda: server.axiomcode_path('a', 'b'), + lambda: server.axiomcode_context('how'), lambda: server.axiomcode_test_impact(), + lambda: server.axiomcode_impact(['A.f'], tests=True, limit=5)] + prose = [lambda: server.axiomcode_impact(['A.f'], full=True), lambda: server.axiomcode_impact(['A.f'], delete=True), + lambda: server.axiomcode_impact(['A.f'], why=True, tests=True), lambda: server.axiomcode_impact(['A.f'], page=2), + lambda: server.axiomcode_path('a', 'b', full=True), lambda: server.axiomcode_context('how', source=True), + lambda: server.axiomcode_context('how', from_='main'), lambda: server.axiomcode_test_impact(why=True), + lambda: server.axiomcode_changed()] + bad = [] + for f in grep: + seen.clear(); f() + if '--grep' not in seen[0]: bad.append(f"sites answer without --grep: {seen[0]}") + seen.clear(); grep[-1]() + if '--grep-limit' not in seen[0]: bad.append(f"limit=5 did not cap the sites: {seen[0]}") + if '--limit' in seen[0]: bad.append(f"limit=5 passed as the prose's --limit under --grep: {seen[0]}") + for f in prose: + seen.clear(); f() + if any(a.startswith('--grep') for a in seen[0]): bad.append(f"prose asked for, got --grep: {seen[0]}") + return bad + finally: + server.run = real + + def check(label, cmd, cwd, env=None, workdir=None, want_err=None): replies, err = exchange(cmd, cwd, env, workdir) if replies is None: @@ -203,6 +239,7 @@ def main(): # the SDK when the launcher finds one, which ignored an argument it did not know (#1567); else the fallback again bad += check_arguments('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo, lax=True) bad += check_words() + bad += check_grep_default() bad += check('symlinked axiomcode mcp', [link, 'mcp'], repo) env_note = 'python3 -S server.py (fallback, no SDK)' bad += check(env_note, [sys.executable, '-S', SERVER], repo) From 8765b07046a646fdfed3e6f3a08c57a82811ae90 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:21:01 -0700 Subject: [PATCH 08/37] changed, impact: a lambda is named by where it is; file:line takes a field or type line (#1426) changed, test-impact and the edit hooks charged an edit inside a lambda to the lambda itself. Every lambda carries the one name , so the edit came back as a signature change of (or Owner. in C#), impact matched every lambda in the graph, and test-impact ran their tests. - changed: an edited line is charged to the narrowest declaration that is not a lambda. An edit inside a lambda in a method is that method's body change; inside a field initializer it is the field's. A lambda nothing encloses is its own body change, named Enclosing. and targeted by file:line. Its parameter list is read from the lambda header, never as a method signature. An expression body after => is no longer read as header text. - path, impact: Owner.method. resolves to that lambda, and a lambda answer is labelled that way. - impact, path: file:line on a field, property, constant or type header line answers for that declaration instead of "no callable spans"; a callable written on the line still wins. Top-level code at a line is the module body that holds it, not the first class body of the file. - Cases for Python, Java and C# with near-miss controls: two lambdas in one method stay distinct, and an edit to a named method next to a lambda stays that method's. --- .../axiomcode/reference/changed-and-tests.md | 8 ++ .../axiomcode/scripts/axiomcode-changed | 66 ++++++++++++-- .../skills/axiomcode/scripts/axiomcode-impact | 24 +++++- .../skills/axiomcode/scripts/axiomcode-path | 86 ++++++++++++++++++- .../axiomcode/reference/changed-and-tests.md | 8 ++ .../lambda-is-named-by-its-place/case.json | 46 ++++++++++ .../new-expression-body.txt | 25 ++++++ .../new-field-lambda.txt | 25 ++++++ .../new-in-lambda.txt | 25 ++++++ .../new-named.txt | 25 ++++++ .../lambda-is-named-by-its-place/old.txt | 25 ++++++ .../src/App/App.csproj | 3 + .../src/App/Orders.cs | 25 ++++++ .../src/App/Widgets.cs | 12 +++ .../lambda-is-named-by-its-place/case.json | 22 +++++ .../new-in-lambda.txt | 19 ++++ .../new-named.txt | 19 ++++ .../java/lambda-is-named-by-its-place/old.txt | 19 ++++ .../src/pkg/Orders.java | 19 ++++ .../lambda-is-named-by-its-place/case.json | 42 +++++++++ .../lambda-is-named-by-its-place/lib.py | 16 ++++ .../new-in-lambda.txt | 16 ++++ .../new-named.txt | 16 ++++ .../new-top-params.txt | 16 ++++ .../lambda-is-named-by-its-place/new-top.txt | 16 ++++ .../lambda-is-named-by-its-place/old.txt | 16 ++++ .../lambda-is-named-by-its-place/test_lib.py | 9 ++ .../test_widgets.py | 5 ++ .../lambda-is-named-by-its-place/widgets.py | 2 + 29 files changed, 646 insertions(+), 9 deletions(-) create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/case.json create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/new-expression-body.txt create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/old.txt create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs create mode 100644 tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs create mode 100644 tests/cases/java/lambda-is-named-by-its-place/case.json create mode 100644 tests/cases/java/lambda-is-named-by-its-place/new-in-lambda.txt create mode 100644 tests/cases/java/lambda-is-named-by-its-place/new-named.txt create mode 100644 tests/cases/java/lambda-is-named-by-its-place/old.txt create mode 100644 tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java create mode 100644 tests/cases/python/lambda-is-named-by-its-place/case.json create mode 100644 tests/cases/python/lambda-is-named-by-its-place/lib.py create mode 100644 tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt create mode 100644 tests/cases/python/lambda-is-named-by-its-place/new-named.txt create mode 100644 tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt create mode 100644 tests/cases/python/lambda-is-named-by-its-place/new-top.txt create mode 100644 tests/cases/python/lambda-is-named-by-its-place/old.txt create mode 100644 tests/cases/python/lambda-is-named-by-its-place/test_lib.py create mode 100644 tests/cases/python/lambda-is-named-by-its-place/test_widgets.py create mode 100644 tests/cases/python/lambda-is-named-by-its-place/widgets.py diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index 074ac4321..844eb7ad2 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -23,6 +23,14 @@ What to pass, and what the answer says when the question cannot be answered the | a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | | fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | +**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never +the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a +field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` +change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that +name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged +header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type +header line answers for that declaration; a callable written on the line still wins. + `test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index acf3b8636..3ee46475a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -256,6 +256,23 @@ class Changed: res.append((name.lstrip('*.'), re.sub(r'\s+', ' ', typ))) return res @staticmethod + def before_expression_body(h): + """a header's text up to an expression body's `=>` written after its parameter list, at bracket depth 0 (a + lambda passed as a default value inside the parameters stays in them)""" + depth = 0; seen = False + for k, ch in enumerate(h): + if ch in '([{<': depth += 1; seen = seen or ch == '(' + elif ch in ')]}>' and not (ch == '>' and k and h[k - 1] == '='): depth = max(0, depth - 1) + elif ch == '=' and h[k + 1:k + 2] == '>' and depth == 0 and seen: return h[:k].rstrip() + return h + @staticmethod + def lambda_header(line): + """the parameter list of the lambda written on a line, as text: `lambda x, y:` (Python), `(x, y) =>` / `x =>` + (C#, Java's `->`); None when the line holds no lambda header""" + t = strip_code(line) + m = re.search(r'\blambda\b([^:]*):', t) or re.search(r'(\([^()]*\)|[A-Za-z_]\w*)\s*(?:=>|->)', t) + return m.group(1).strip().strip('()').strip() if m else None + @staticmethod def field_parts(line): """(declared type, name) of a field line, in either spelling: `Type name = …;` or `name: Type = …`""" t = re.sub(r'\s*(//.*|/\*.*?\*/\s*|#.*)$', '', line).strip() @@ -300,11 +317,26 @@ class Changed: if not old.strip() and new.strip(): return self.new_file(rel, new, decls, is_py), [] if not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)] hits = {} - def narrowest(ln, kinds=None): + # A LAMBDA IS PART OF WHAT ENCLOSES IT. Every lambda carries one name (``), so taken as the declaration + # an edit changed, a body edit inside one came back as "signature -x", handed to impact as ``, + # which matched every lambda in the graph (hundreds, most of them in unrelated fixtures), and test-impact ran + # their tests. An edited line is charged to the narrowest declaration that is not a lambda; a lambda stands on + # its own only where no method or field encloses it (top-level code), named `Enclosing.` and + # targeted by file:line (lambda_decl). + is_lam = lambda d: d[5] in P.LAMBDA_NAMES and d[3] not in ('field', 'const', 'enum_member', 'variable') + def narrowest(ln, kinds=None, lambdas=False): best = None for a, b, i, k, d, n in decls: + if is_lam((a, b, i, k, d, n)) != lambdas: continue if a <= ln <= b and (kinds is None or k in kinds) and (best is None or (b - a) < (best[1] - best[0])): best = (a, b, i, k, d, n) return best + def lambda_decl(ln, m): + """the lambda that stands on its own at ln: one spans the line, and no method (a module body is not one) + holds it, nor a field whose initializer it is""" + lam = narrowest(ln, None, lambdas=True) + if not lam or (m and m[3] != 'module' and m[0] <= lam[0] and lam[1] <= m[1]): return None + if any(k in ('field', 'const', 'enum_member', 'variable') and a <= lam[0] <= max(b, a) for a, b, i, k, d, n in decls): return None + return lam def decl_after(ln, L): """the declaration whose header starts within 3 lines after ln (an annotation / decorator line belongs to it)""" for a, b, i, k, d, n in decls: @@ -322,8 +354,13 @@ class Changed: for ln in sorted(old_changed): if OL[ln - 1].strip().startswith('@') and any(ln in range(1, dd[0]) for dd in decorated): continue f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if a == ln and k in ('field', 'const', 'enum_member', 'variable')), None) + # a lambda's lines inside a field's initializer are the field's + if not f: f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if k in ('field', 'const', 'enum_member', 'variable') and a < ln + and any(is_lam(x) and x[0] <= ln <= x[1] and a <= x[0] <= max(b, a) for x in decls)), None) m = narrowest(ln, {'method', 'function', 'constructor', 'module'}) t = narrowest(ln, {'class', 'interface', 'enum', 'type', 'namespace'}) + lam = None if f else lambda_decl(ln, m) + if lam: hits.setdefault(('lambda', lam), set()).add(ln); continue key = None # a callable that starts on the type's own header line and ends within the type is one the front end # synthesised for it (an implicit constructor, a Python class body): the header is the TYPE's, or a new class @@ -356,6 +393,15 @@ class Changed: decs = decorated.get((a, b, i, k, d, n)) if a > len(OL): continue # starts past the committed file: nothing of it is there entry = dict(kind=kind, symbol=d, id=i, file=rel, line=a, end=b, old_lines=sorted(x for x in lines if x > 0), target_kind=('param' if kind == 'signature' else KIND(k))) + if kind == 'lambda': + # a lambda at the top level: its own body. Named by where it is, targeted by file:line in the graph's own + # lines; its parameter list is compared as written, so an unchanged header is never a parameter diff + entry.update(kind='body', symbol=self.g.lambda_label(i), target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method', lambda_=True) + na = new_of.get(a) + oh, nh = self.lambda_header(OL[a - 1] if a <= len(OL) else ''), self.lambda_header(NL[na - 1] if na and na <= len(NL) else '') + if oh is not None and nh is not None and re.sub(r'\s', '', oh) != re.sub(r'\s', '', nh): entry['detail'] = f"lambda parameters ({oh}) → ({nh})" + if a in removed_lines and all(x in removed_lines for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])): entry['kind'] = 'removed' + out.append(entry); continue span_lines = [x for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])] gone = a in removed_lines and span_lines and sum(1 for x in span_lines if x in removed_lines) >= 0.6 * len(span_lines) and \ not any(re.search(rf'\b{re.escape(n)}\s*\(', NL[j - 1]) and ndepth_ok(j) for j in range(max(1, (new_of.get(a) or a) - 3), min(len(NL), (new_of.get(a) or a) + 3) + 1)) @@ -373,8 +419,13 @@ class Changed: if na and na <= len(NL): # the new header: from the mapped line, the first line holding the name, to its end for s in range(max(1, na - 2), min(len(NL), na + 6) + 1): - if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]): + # read with comments blanked: a comment line above the header that mentions the name is not it + if re.search(rf'\b{re.escape(n)}\b', strip_code(NL[s - 1], strings=False, hash_comments=is_py)): nh_raw = ' '.join(x.strip() for x in NL[s - 1:self.header_end(NL, s)]); nh = uncomment(NL[s - 1:self.header_end(NL, s)]); break + # AN EXPRESSION BODY IS NOT HEADER. `int Count() => xs.Count(x => x > 0);` is one line, and read to its `;` + # the whole body was header text: an edit inside the lambda it holds came back as a signature change + body_arrow = self.before_expression_body + oh, oh_raw, nh, nh_raw = body_arrow(oh), body_arrow(oh_raw), body_arrow(nh), body_arrow(nh_raw) # the header may start on an annotation line: its arguments are not a parameter list, and its text is # not a return type. Read the signature from the first line that is not a decoration sig = lambda h: re.sub(r'^\s*(@\w[\w.]*\s*(\([^)]*\))?\s*)+', '', h).strip() @@ -574,10 +625,10 @@ class Changed: for r in self.g.q(f"SELECT e.callee_method_id m, s.file f, s.display d FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id IN ({q})", *mids): if r['f'] != rel: callers.setdefault(r['m'], set()).add(r['d']) for m, cs in callers.items(): - i = mids[m]; sy = self.g.all_sym[i] - out.append(dict(kind='added', symbol=sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[], + i = mids[m]; sy = self.g.all_sym[i]; lam = self.g.is_lambda(i) + out.append(dict(kind='added', symbol=self.g.lambda_label(i) if lam else sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[], detail=f"new, and already called from outside this file ({len(cs)} caller(s): {', '.join(sorted(cs)[:3])}{' …' if len(cs) > 3 else ''})", - target=self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True)) + target=(self.g.lambda_target(i) if lam else None) or self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True)) return out def whole_file(self, rel, why): """every callable the graph declares in a NAMED file, as changed: what `test-impact ` means when there is no @@ -586,6 +637,11 @@ class Changed: pick = [d for d in spans if d[3] in ('method', 'function', 'constructor')] or [d for d in spans if d[3] in ('class', 'interface', 'enum', 'type')] out = [] for a, b, i, k, d, n in pick: + if n in P.LAMBDA_NAMES: + # a lambda inside a method of this file is counted with that method; one at the top level by its line + if self.g.enclosing(i): continue + out.append(dict(kind='named', symbol=self.g.lambda_label(i), id=i, file=rel, line=a, end=b, old_lines=[], detail=why, + target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method')); continue out.append(dict(kind='named', symbol=d, id=i, file=rel, line=a, end=b, old_lines=[], detail=why, target=self.qualified(i, 'method') or d, target_kind='type' if k in ('class', 'interface', 'enum', 'type') else 'method')) if not out: out.append(dict(kind='named', symbol=rel, id=None, file=rel, line=1, end=1, old_lines=[], detail=why + '; the graph declares nothing in it', target=None)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 1a3acbe37..eecd90ed7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -294,6 +294,8 @@ class Impact: resolver already matched that path against the index, so there is no prefix left to check.""" m = re.match(r'^(.+?):(\d+)$', typed.strip().strip('`"\'')) if m and ('/' in m.group(1) or '.' in m.group(1)): return '' + # `Owner.m.`: the resolver matched the enclosing member it names, so its prefix is checked + if re.search(r'<\w+@L\d+>$', typed.strip()): return '' typed = re.sub(r'\(.*\)$', '', typed).replace('#', '.').strip('.') if typed.count('.') < 2: return '' # Owner.member carries no prefix to validate names = [] @@ -376,6 +378,21 @@ class Impact: f" properties file, a YAML, an XML). Do not read this as 'nothing uses it'.") return [('string', f"the string {raw} ({n} literal site(s), {d} decoration site(s))", v)] s = raw.strip('`"\'') + # FILE:LINE ON A FIELD, A PROPERTY OR A TYPE HEADER is that declaration. The line resolver takes callables, so + # such a line answered `no callable spans` in every language (a class line in C#, #1426); the declaration at + # the line is asked about as its name would be, and a callable written on the line still wins (G.decl_at_line) + mfl = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) + if mfl and ('/' in mfl.group(1) or '.' in mfl.group(1)) and kind in (None, 'field', 'type'): + f, ln = self.g.repo_file(mfl.group(1))[0], int(mfl.group(2)) + files = sorted({x['file'] for x in self.g.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") + if x['file'] == f or (x['file'] or '').endswith('/' + f)}) + if f in files: files = [f] + d = self.g.decl_at_line(files[0], ln) if len(files) == 1 else None + if d and d[0] == 'field' and kind in (None, 'field'): + row = self.fields.get(d[1]['rowid']) or d[1] + return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] + if d and d[0] == 'type' and kind in (None, 'type'): + return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])] # a lowercase dotted name is read as a configuration key when the graph has configuration facts, or when its last # segment names nothing the code declares; `util.square` ending in a real function is a declaration that missed if (kind == 'config' or (not kind and self.CONFIG_RE.match(s) and (self.has_config() or not self.declared_name(s.rsplit('.', 1)[-1])) @@ -463,7 +480,9 @@ class Impact: # a bodiless signature beside the function it annotates (file:line on `const f: T = …`) is ONE declaration bodied = [i for i in mids if self.g.method_kind(i) not in BODILESS_KINDS] if mids else [] one = len(mids) == 1 or (len(bodied) == 1 and ':' in s) - if mids: out.append(('method', (self.g.disp((bodied or mids)[0]) + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids)) + # a lambda is named by where it is (G.lambda_label), not by the one name every lambda carries + if mids: nm0 = (bodied or mids)[0]; shown = self.g.name(nm0) + if mids: out.append(('method', (shown + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids)) if not out: # an inherited member is written under the name the code uses: Sub.member, declared in a base. Answer for the # declaration and say where it is, instead of falling through to a name match that no rule can act on @@ -506,7 +525,8 @@ class Impact: if soft: return [] print(buf.getvalue(), end=''); raise last = re.sub(r'\(.*\)$', '', s).replace('#', '.').replace('$', '.').rstrip('.').split('.')[-1] - ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s)] + lam = bool(re.search(r'<\w+@L\d+>$', s)) # Owner.m.: the lambda at that line + ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s or lam)] if not ids and not soft: die(f"{s} is not a method in the graph") return ids def types(self, s, soft=False): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 404481d61..60337ece3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -65,6 +65,9 @@ def chain_json(g, chain): # declarations that describe a callable and have no body (score.py's bodiless_kinds) BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE', 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} +# the one name a front end gives every lambda it declares (Python and C#; Java declares none): a name that says nothing +# about WHICH lambda, so it is never a target on its own (G.lambda_label, G.lambda_target) +LAMBDA_NAMES = {''} # ONE SPELLING FOR EVERY LANGUAGE. Each front end writes a qualified name its own way: Java and C# `pkg.Owner.m`, # Python `pkg.module.f`, TypeScript `src/util#square`, JavaScript `src/util.square`. canon() maps all of them onto the @@ -198,6 +201,61 @@ class G: def disp(self, i): r = self.sym.get(i) or self.all_sym.get(i); return r['display'] if r else i + # ── a lambda is named by where it is written ───────────────────────────────────────────────────────────── + # Every lambda of a language is declared under ONE name (``), so the name alone matches every lambda in the + # graph: an edit next to one was handed to impact as ``, which answered for hundreds of declarations, and + # the lambdas of one class were one symbol `Owner.`. A lambda is identified by the member that encloses it + # and its line, `Owner.method.`, and targeted by `file:line`, which names that one declaration. + def is_lambda(self, i): + r = self.all_sym.get(i) or self.sym.get(i) + return bool(r) and r.get('name') in LAMBDA_NAMES and r.get('method_id') is not None + def enclosing(self, i): + """the narrowest declaration other than a lambda or a module body whose span holds lambda `i`: a method, a + field whose initializer it is, or a type. None at the top level of a file.""" + r = self.all_sym.get(i) or self.sym.get(i) + if not r or not r.get('file') or not r.get('line'): return None + a, b = r['line'], r.get('end_line') or r['line'] + rows = self.q("""SELECT id, rowid, display, kind, name, line, end_line FROM symbols WHERE file = ? AND line <= ? AND COALESCE(end_line, line) >= ? + AND id IS NOT ? AND kind NOT IN ('module', 'library', 'written') AND name NOT IN (%s) + ORDER BY COALESCE(end_line, line) - line, CASE WHEN method_id IS NOT NULL THEN 0 WHEN type_id IS NULL THEN 1 ELSE 2 END""" % ','.join('?' * len(LAMBDA_NAMES)), + r['file'], a, b, i, *sorted(LAMBDA_NAMES)) + return dict(rows[0]) if rows else None + def lambda_label(self, i): + """`Owner.method.`: the enclosing member's name and the lambda's line""" + r = self.all_sym.get(i) or self.sym.get(i) + if not r: return str(i) + e = self.enclosing(i) + if e: pre = e['display'] + else: + q = (r.get('qualified_name') or '').replace('.', '') + pre = q.rsplit('.', 1)[0] if '.' in q and not q.rsplit('.', 1)[0].endswith('>') else (r.get('owner') or os.path.splitext(os.path.basename(r.get('file') or ''))[0]) + return f"{pre}.{r['name'][:-1]}@L{r.get('line')}>" + def decl_at_line(self, f, ln, spanning=None): + """('field', row) or ('type', row) when `f:ln` is the declaration line of a field, a constant, a property or a + type and no callable of its own is written there; None otherwise. A file:line names "the declaration at that + line", and the resolver took callables only, so a field line answered `no callable spans` in every language + and a C# class line too (#1426). What a front end synthesises ON such a line is not a callable of its own: a + lambda in a field's initializer is part of the field, and an implicit constructor on a type's header line is + the type's (a Python class body is a module node and is never a candidate here).""" + if spanning is None: + spanning = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) + fields = [dict(x) for x in self.q("SELECT rowid, * FROM symbols WHERE file = ? AND line = ? AND method_id IS NULL AND type_id IS NULL AND kind IN ('field','const','enum_member','variable')", f, ln)] + types = [dict(x) for x in self.q("SELECT * FROM symbols WHERE file = ? AND line = ? AND type_id IS NOT NULL AND method_id IS NULL", f, ln)] + if not fields and not types: return None + fend = max([x.get('end_line') or x['line'] for x in fields], default=ln) + for x in spanning: + s = self.sym.get(x['id']) or self.all_sym.get(x['id']) or {} + if fields and self.is_lambda(x['id']) and ln <= (s.get('line') or 0) and (s.get('end_line') or 0) <= fend: continue + if types and s.get('kind') == 'constructor' and s.get('line') == ln and (s.get('end_line') or ln) == ln: continue + return None # a callable of its own is written on this line + return ('field', fields[0]) if fields else ('type', types[0]) + def name(self, i): + """the name a reader is shown for a declaration: its display, and a lambda's place (lambda_label)""" + return self.lambda_label(i) if self.is_lambda(i) else self.disp(i) + def lambda_target(self, i): + r = self.all_sym.get(i) or self.sym.get(i) + return f"{r['file']}:{r['line']}" if r and r.get('file') and r.get('line') else None + # ── endpoints: exact, or a short list of what is close ─────────────────────────────────────────────────── IN = None def resolve(self, sel, fragment=False): @@ -268,6 +326,18 @@ class G: # the smallest callable(s) spanning the line — several when they share it (`class Item { run(){} tag(){} }`); # top-level code belongs to the file's module node, which is a caller like any other r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) + d = self.decl_at_line(f, ln, r) + if d and d[0] == 'type': # a type's header line: the type, as its name answers + t = d[1] + ids = [x['id'] for x in self.q("SELECT id FROM symbols WHERE owner = ? AND file = ? AND method_id IS NOT NULL", t['display'], f)] + if t['id'] in self.callers_ids: ids.append(t['id']) + if ids: return f"{t['display']} (type at {s}: {len(ids)} methods)", ids + if d and d[0] == 'field': # a field's line: what its name answers + fr = d[1]; nm = f"{fr['owner']}.{fr['name']}" if fr.get('owner') else fr['name'] + try: + with contextlib.redirect_stdout(io.StringIO()): label, ids = self._resolve(nm) + return f"{label} (field at {s})", ids + except SystemExit: pass if r: ids = [x['id'] for x in r if x['span'] == r[0]['span']] # AN ANNOTATION IS NOT THE FUNCTION. `export const f: (a: A) => B = (a) => …` puts a one-line call @@ -277,8 +347,11 @@ class G: if self.has('methods') and all(self.method_kind(i) in BODILESS_KINDS for i in ids): start = {self.sym[i].get('line') for i in ids if i in self.sym} ids += [x['id'] for x in r if x['id'] not in ids and self.sym.get(x['id'], {}).get('line') in start and self.method_kind(x['id']) not in BODILESS_KINDS] - return (f"{self.disp(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids - r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL LIMIT 1", f) + return (f"{self.name(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids + # top-level code: the module body that holds the line. A Python file has two (the file's and each class + # body's), and the first row came back whatever the line, so a module-level constant below a class was + # answered as that class's body + r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL ORDER BY (line <= ? AND COALESCE(end_line, line) >= ?) DESC, COALESCE(end_line, line) - line LIMIT 1", f, ln, ln) if r: return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] if outside: die(f"{m.group(1)} is outside the indexed repository {self.repo}: give the file relative to that root, or ask the graph of the repository it belongs to") die(f"no callable spans {s}") @@ -312,6 +385,15 @@ class G: # printed the near miss `Router.#handleError`, which is what had just been asked for: a private method could not # be asked about at all, in the language where private methods are written that way. So the spelling as # written is tried against the index BEFORE any separator is rewritten. + # `Owner.method.`, the name changed and test-impact print for a lambda: the lambda at that line + # whose enclosing member is the prefix written + ml = re.match(r'^(?:(.*)\.)?<(\w+)@L(\d+)>$', s) + if ml: + want = canon(ml.group(1) or '') + ids = [x['id'] for x in self.q("SELECT id FROM symbols WHERE name = ? AND line = ? AND method_id IS NOT NULL", f"<{ml.group(2)}>", int(ml.group(3))) + if not want or canon_is(self.lambda_label(x['id']).rsplit('.<', 1)[0], want)] + if ids: return (s if len(ids) == 1 else f"{s} ({len(ids)} declarations)"), ids + die(f"no <{ml.group(2)}> starts on line {ml.group(3)}" + (f" inside {ml.group(1)}" if ml.group(1) else '') + ": ask by file:line") verbatim = re.sub(r'\(.*\)$', '', s).strip() if '#' in verbatim: hit = self.q("SELECT id FROM symbols WHERE (display = ? OR qualified_name = ?) AND method_id IS NOT NULL", verbatim, verbatim) diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index 074ac4321..844eb7ad2 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -23,6 +23,14 @@ What to pass, and what the answer says when the question cannot be answered the | a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | | fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | +**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never +the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a +field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` +change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that +name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged +header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type +header line answers for that declaration; a callable written on the line still wins. + `test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/case.json b/tests/cases/csharp/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..f9860d7bf --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/case.json @@ -0,0 +1,46 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of THAT METHOD, never a signature change of `Owner.`: the lambdas of a class all carry that one name", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "src/App/Orders.cs"], + "want": ["body Orders.Totals"], + "avoid": ["` is not its header", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-expression-body.txt", "--file", "src/App/Orders.cs"], + "want": ["body Orders.Positive"], + "avoid": [""], + "avoid": ["", "declarations"]}, + {"why": "and the name printed for a lambda is a target that answers for that lambda alone", + "run": ["impact", "Orders.Totals."], + "want": ["change: Orders.Totals."], + "avoid": ["", "declarations"]}, + {"why": "file:line on a field line is that field, not `no callable spans`", + "run": ["impact", "src/App/Orders.cs:9"], + "want": ["field _count"], + "avoid": ["no callable spans"]}, + {"why": "file:line on a field whose initializer is a lambda is the field, not the lambda", + "run": ["impact", "src/App/Orders.cs:10"], + "want": ["field _twice"], + "avoid": ["no callable spans", " _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x >= 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt new file mode 100644 index 000000000..abd0dc7c5 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 3; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt new file mode 100644 index 000000000..ebb5e4265 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x >= 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..a19816709 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count + 1; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/old.txt b/tests/cases/csharp/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..90a479d5f --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj new file mode 100644 index 000000000..c3c355498 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs new file mode 100644 index 000000000..90a479d5f --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs new file mode 100644 index 000000000..dedff2d02 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Widgets +{ + public List Sorted(List ws) + { + return ws.OrderBy(w => w.Length).ToList(); + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/case.json b/tests/cases/java/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..0ed46cac3 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/case.json @@ -0,0 +1,22 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of that method", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "src/pkg/Orders.java"], + "want": ["body Orders.totals"], + "avoid": [" totals(List xs) { + return xs.stream() + .filter(x -> x >= 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/new-named.txt b/tests/cases/java/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..59236a586 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit + 1; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/old.txt b/tests/cases/java/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..22005c333 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java b/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java new file mode 100644 index 000000000..22005c333 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/case.json b/tests/cases/python/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..4f253f04b --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/case.json @@ -0,0 +1,42 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of THAT METHOD: the lambda is part of its body, and its one name, , is never the declaration an edit is charged to (it matched every lambda in the graph, and their tests)", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "lib.py"], + "want": ["body Orders.totals", "→ impact Orders.totals"], + "avoid": ["", "impact lib.py:15"], + "avoid": [" signature ", "impact "]}, + {"why": "its parameter list is read from the lambda's own header, never as a method signature", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-top-params.txt", "--file", "lib.py"], + "want": ["lambda parameters (xs) → (ys)", "impact lib.py:15"], + "avoid": [" signature ", "-lambda"]}, + {"why": "two lambdas in one method are two declarations: file:line names one of them, by its enclosing method and line", + "run": ["impact", "lib.py:6"], + "want": ["Orders.totals."], + "avoid": ["", "declarations"]}, + {"why": "and the name printed for a lambda is a target that answers for that lambda alone", + "run": ["impact", "Orders.totals."], + "want": ["change: Orders.totals."], + "avoid": ["", "declarations"]}, + {"why": "file:line on a module-level constant is that constant, not the body of the class above it", + "run": ["impact", "lib.py:14"], + "want": ["const HANDLERS"], + "avoid": ["change: Orders.", "no callable spans"]}, + {"why": "file:line on a class line is the class", + "run": ["impact", "lib.py:1"], + "want": ["class Orders"], + "avoid": ["change: Orders."]}, + {"why": "control: file:line inside a method body is still that method, though the line assigns a field", + "run": ["impact", "lib.py:3"], + "want": ["Orders.__init__ (at lib.py:3)"], + "avoid": ["change: field"]}]} diff --git a/tests/cases/python/lambda-is-named-by-its-place/lib.py b/tests/cases/python/lambda-is-named-by-its-place/lib.py new file mode 100644 index 000000000..8fef54f59 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/lib.py @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt b/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt new file mode 100644 index 000000000..bd5ee6a14 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x >= 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-named.txt b/tests/cases/python/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..78cd1830e --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.title for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt b/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt new file mode 100644 index 000000000..2f20d1d39 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda ys: sum(ys), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-top.txt b/tests/cases/python/lambda-is-named-by-its-place/new-top.txt new file mode 100644 index 000000000..609b59f6f --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-top.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs) + 1, +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/old.txt b/tests/cases/python/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..8fef54f59 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/test_lib.py b/tests/cases/python/lambda-is-named-by-its-place/test_lib.py new file mode 100644 index 000000000..7d7ad277a --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/test_lib.py @@ -0,0 +1,9 @@ +from lib import Orders + + +def test_totals(): + assert Orders().totals([1, -1]) == [2] + + +def test_names(): + assert Orders().names() == [] diff --git a/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py b/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py new file mode 100644 index 000000000..3d121ed70 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py @@ -0,0 +1,5 @@ +from widgets import label + + +def test_label(): + assert label([]) == [] diff --git a/tests/cases/python/lambda-is-named-by-its-place/widgets.py b/tests/cases/python/lambda-is-named-by-its-place/widgets.py new file mode 100644 index 000000000..710a81484 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/widgets.py @@ -0,0 +1,2 @@ +def label(ws): + return sorted(ws, key=lambda w: w.name) From e4a447239127a5ceef65ba52d5f4c59263fc716f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:11:33 -0700 Subject: [PATCH 09/37] index: the live graph link is relative to its directory, so a copied checkout keeps its own graph graph.sqlite pointed at its graph by an absolute path. A copy of the repository kept it: the copy answered from the original's graph without its own edits, and once the original moved it found the link dangling and rebuilt itself as "a build was interrupted". - axiomcode-build makes the link from inside .axiomcode/out, relative (typescript/graph.sqlite, .live.sqlite during a rebuild), and resolves a relative link where it reads one. - ax_fresh.relink, run by every query verb, by the build and by `graph`: an absolute link an older build left is re-pointed at this checkout's own graph. Into another checkout it says so (copied, or moved when the original is gone); with no graph here to take, the link is removed and one is built. - "a build was interrupted" now names the dangling target, and is said only for this checkout's own link. Tests (refresh_races.py, copied checkout): a fresh index writes a relative link; a copy reads and rebuilds its own graph; an absolute link into another checkout, still there or gone, is re-pointed with no rebuild. Controls: the original does not see the copy's edit; an absolute link into its own graph is kept silently. Closes #1605 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_contract.py | 10 +++-- .../skills/axiomcode/scripts/ax_fresh.py | 33 ++++++++++++++++ .../skills/axiomcode/scripts/axiomcode-build | 7 +++- .../skills/axiomcode/scripts/axiomcode-graph | 1 + tests/refresh_races.py | 38 +++++++++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index d7d18c2ac..65cec6498 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -406,13 +406,14 @@ def ensure_graph(repo, db): killed every time, cache nothing, and repeat on the next edit forever. AXIOMCODE_GRAPH points at a graph someone else built and placed; nothing is built into it.""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + rr = os.path.realpath(repo); auto = os.environ.get('AXIOMCODE_AUTOBUILD') + if not os.environ.get('AXIOMCODE_GRAPH'): ax_fresh.relink(rr) # a pointer into another checkout is not this graph (#1605) if os.path.exists(db): return True # A BUILD IS RUNNING: wait for it, never start a second one (#1305). The graph (or the baseline graph `changed` reads, # or another language's) can be missing for a moment while a build swaps it; a query that took that for "no graph" # started a full build of its own, which queued behind the running one and then rebuilt everything again as an # explicit index. The hooks, which run under timeouts of seconds, do not wait. - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh - rr = os.path.realpath(repo); auto = os.environ.get('AXIOMCODE_AUTOBUILD') if ax_fresh.building(rr): # a build is running: never a silent wait. Under the MCP server answer at once with the stage it is at; from a # shell wait for it, printing the stage as it moves; either way, use the graph the moment it exists @@ -445,7 +446,10 @@ def ensure_graph(repo, db): # a graph WAS built here and its pointer is broken: a repair, which keeps the baseline `changed` and test-impact # measure edits against, as the background refresh does. Built as a first index it moved the baseline to the # edited tree, and every edit made before it dropped out of `changed` - print(f"the graph of {repo} is missing (a build was interrupted) — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) + # relink above took every pointer that leaves this .axiomcode/out: one still here is this repository's own + try: gone = f" (.axiomcode/out/graph.sqlite points at {os.readlink(db)}, which is not there)" + except OSError: gone = '' + print(f"the graph of {repo} is missing (a build was interrupted){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) env = dict(os.environ, AXIOMCODE_KEEP_BASE='1') else: print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 9790916f4..e39715726 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -261,6 +261,37 @@ def has_graph(repo): def graph_broken(repo): return has_graph(repo) and not os.path.exists(os.path.join(out_dir(repo), 'graph.sqlite')) +def relink(repo, locked=False, say=True): + """graph.sqlite points into THIS repository's .axiomcode/out, by a name relative to it (#1605). A build before this + wrote an absolute pointer, which a copy of the repository kept: the copy answered from the original's graph and never + saw its own edits, and once the original moved it found its pointer dangling and rebuilt itself as "a build was + interrupted". A pointer that leaves this .axiomcode/out is re-pointed at the same graph here (the copy's own), and + says which case it was; one with no graph here to take is removed, so the query builds one. Returns what it did.""" + out = out_dir(repo); p = os.path.join(out, 'graph.sqlite') + if not os.path.islink(p) or (not locked and building(repo)): return '' # a running build owns the pointer + try: t = os.readlink(p) + except OSError: return '' + if not os.path.isabs(t): return '' # relative: this out/'s own; dangling, it is an interrupted build to repair + parts = t.replace('\\', '/').rsplit('/.axiomcode/out/', 1) + tail = parts[1] if len(parts) == 2 and not os.path.normpath(parts[1]).startswith('..') else '' + own = os.path.join(out, tail) if tail else '' + there = parts[0] + '/.axiomcode/out' if tail else '' + # an older build's pointer into this very out/ stays this graph, dangling or not (a dangling one is a repair) + ours = bool(there) and os.path.isdir(there) and os.path.samefile(there, out) + try: + if ours or own and os.path.exists(own): + tmp = os.path.join(out, '.graph.sqlite.relink') + if os.path.lexists(tmp): os.remove(tmp) + os.symlink(tail, tmp); os.replace(tmp, p) + else: os.remove(p) + except OSError: return '' + if ours: return 'relative' + msg = (f"graph.sqlite pointed at another checkout's graph ({t})" + (" — this repository was copied from it" if os.path.exists(t) + else ", which is gone — this repository was moved or copied from it") + + (f"; re-pointed at its own graph (.axiomcode/out/{tail})" if own and os.path.exists(own) else "; this checkout has no graph of its own there, so one is built")) + if say: print(msg, file=sys.stderr) + return msg + def graph_lang(): """the language whose graph a verb reads when it is not the main one (AXIOMCODE_GRAPH_LANG, set by the dispatcher as it asks each language's graph in turn), else ''""" @@ -837,6 +868,8 @@ def main(argv): if cmd == 'lock': return 0 if _flock(int(argv[2]), '--try' not in argv) else 75 repo = os.path.realpath(argv[2]) if len(argv) > 2 else os.getcwd() + if cmd in ('query', 'baseline', 'relink'): relink(repo, locked='--locked' in argv[3:4]) # every query verb passes here + if cmd == 'relink': return 0 if cmd == 'snapshot': lang, src_arg = argv[3], (argv[4] if len(argv) > 4 else '') lib = os.environ.get('AXIOMCODE_LIBRARY', '') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 9d15fe1c7..04b4a0f05 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -135,6 +135,7 @@ if ! python3 "$H/ax_fresh.py" lock 9 --try; then [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "a graph build is already running for $REPO; waiting for it …" python3 "$H/ax_fresh.py" lock 9 || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } fi +python3 "$H/ax_fresh.py" relink "$REPO" --locked || true # a pointer copied from another checkout is this one's again (#1605) BUILD_T0=$SECONDS # how long a build takes to a usable graph: a query decides from it whether to wait (#1595) # THE QUERY RULES COMPILE BESIDE THE ENGINE, NOT AFTER THE GRAPH (#1606). They do not depend on the graph, only on this # plugin's dl/*.dl, and impact.dl alone takes 45-140 s. Started once the graph was published, the compile raced the first @@ -224,6 +225,7 @@ build_head_graph(){ if env -u AXIOMCODE_BACKGROUND -u AXIOMCODE_KEEP_BASE AXIOMCODE_NO_REFRESH=1 AXIOMCODE_LIBRARY="$LIBS" bash "$H/axiomcode-build" "$T" > "$REPO/.axiomcode/base-build.log" 2>&1; then # graph.sqlite is a symlink, or (Windows without the symlink privilege) the copy Git Bash made instead of one D="$(readlink "$T/.axiomcode/out/graph.sqlite" 2>/dev/null || true)"; [ -n "$D" ] || D="$T/.axiomcode/out/graph.sqlite" + case "$D" in /*|[A-Za-z]:*) ;; *) D="$T/.axiomcode/out/$D" ;; esac # the pointer is relative to its directory if [ -f "$D" ]; then local NB="$REPO/.axiomcode/base.new"; rm -rf "$NB"; mkdir -p "$NB/out"; mv "$D" "$NB/out/graph.sqlite" for D in "$T"/.axiomcode/lang/*/out/graph.sqlite; do # and every other language's, beside it @@ -295,7 +297,10 @@ rm -rf "$PREV" # is seconds); only then does the build fail, and says why. swap(){ local i=0; until mv -f "$1" "$2" 2>/dev/null; do i=$((i+1)); [ $i -ge 120 ] && { echo "could not replace $2: another process holds it open" >&2; return 1; }; sleep 0.5; done; } keep_live(){ ln -f "$1" "$OUT/.live.sqlite" 2>/dev/null || cp -p "$1" "$OUT/.live.sqlite"; } # a hard link, or a copy where the file system has none -point(){ ln -sfn "$1" "$OUT/.graph.sqlite.new" && swap "$OUT/.graph.sqlite.new" "$OUT/graph.sqlite"; } +# THE POINTER IS RELATIVE TO ITS DIRECTORY (#1605). An absolute one survived a copy of the repository: the copy answered +# from the original's graph, and once the original moved it rebuilt itself as "interrupted". Made from inside $OUT, so +# Git Bash's copy in place of a link resolves the same relative name. +point(){ local t="${1#"$OUT"/}"; (cd "$OUT" && ln -sfn "$t" .graph.sqlite.new) && swap "$OUT/.graph.sqlite.new" "$OUT/graph.sqlite"; } real(){ python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null; } if [ -d "$OUT/$LANG_ARG" ]; then if [ -f "$OUT/$LANG_ARG/graph.sqlite" ]; then diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index 38a3b38a9..478b64f18 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -256,6 +256,7 @@ def build(repo, page, library=None): db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') asked = [f"{k}={os.environ[v]}" for k, v in (('--lang', 'AXIOMCODE_LANG'), ('--src', 'AXIOMCODE_SRC'), ('--library', 'AXIOMCODE_LIBRARY')) if os.environ.get(v)] if os.environ.get('AXIOMCODE_GRAPH'): return export(repo, page) # a graph someone else built and placed: drawn as it is + ax_fresh.relink(repo) # never another checkout's graph (#1605) if not os.path.lexists(db): # no graph yet: the first index, with what was asked for (every language present, when nothing was) t0 = time.time(); run_build(repo) diff --git a/tests/refresh_races.py b/tests/refresh_races.py index 38bc752e9..37879cff3 100644 --- a/tests/refresh_races.py +++ b/tests/refresh_races.py @@ -16,6 +16,11 @@ one) and impact answers. Control: a failed build with the same main language puts that language's directory back and leaves no stray link. failure reason a background refresh that fails says WHY in the note a query prints, not only "see build.log". + copied checkout graph.sqlite points at its graph by a relative name, so a copy reads its own graph and sees its + own edits (#1605). An absolute pointer an older build left, into another checkout that is still + there or gone, is re-pointed at the copy's graph and said as such, never "a build was + interrupted". Controls: the original does not see the copy's edit; an absolute pointer into + its own graph is kept silently. gitignore the index and the refresher skip what git ignores: a generated tree .gitignore names is neither parsed nor watched. Controls: a tracked file under an ignored name is read; AXIOMCODE_NO_GITIGNORE=1 reads everything. @@ -157,6 +162,39 @@ def hold(): 'control: a failed build with the same main language puts its directory back, with no stray link', f"{os.path.realpath(mptr)} live={os.path.exists(os.path.join(mout, '.live.sqlite'))}") + # ── copied checkout ─────────────────────────────────────────────────────────────────────────────────── + orig = repo_with(work, 'orig', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL}) + b = sh(orig, AX, 'index', '.', env=env) + optr = os.path.join(orig, '.axiomcode', 'out', 'graph.sqlite') + check(b.returncode == 0 and os.path.islink(optr) and not os.path.isabs(os.readlink(optr)), + 'copied checkout: graph.sqlite points at its graph by a name relative to its directory', f"{os.readlink(optr) if os.path.islink(optr) else '?'}\n{b.stdout[-400:]}") + cp = os.path.join(work, 'copy'); shutil.copytree(orig, cp, symlinks=True) + cptr = os.path.join(cp, '.axiomcode', 'out', 'graph.sqlite') + open(os.path.join(cp, 'src', 'util.ts'), 'a').write('\nexport function third(): number {\n return helper()\n}\n') + check(os.path.realpath(cptr).startswith(os.path.realpath(cp) + os.sep), 'copied checkout: the copy reads its own graph', os.path.realpath(cptr)) + b = sh(cp, AX, 'index', '.', env=env) + r = sh(cp, AX, 'impact', 'helper', '.', env=quiet) + check(b.returncode == 0 and r.returncode == 0 and 'third' in r.stdout and os.path.realpath(cptr).startswith(os.path.realpath(cp) + os.sep), + 'copied checkout: the copy rebuilds its own graph and sees its own edit', b.stdout[-400:] + r.stdout[-600:] + r.stderr[-600:]) + r = sh(orig, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'third' not in r.stdout and 'caller' in r.stdout, 'control: the original does not see the copy\'s edit', r.stdout[-400:]) + # a pointer an older build left: absolute, into the original, whether the original is still there or gone + leg = os.path.join(work, 'legacy'); shutil.copytree(orig, leg, symlinks=True) + lptr = os.path.join(leg, '.axiomcode', 'out', 'graph.sqlite'); logp = os.path.join(leg, '.axiomcode', 'build.log') + for case, target in (('the original still there', os.path.realpath(optr)), + ('the original moved away', os.path.join(work, 'gone', '.axiomcode', 'out', 'typescript', 'graph.sqlite'))): + os.remove(lptr); os.symlink(target, lptr); logm = os.path.getmtime(logp) + r = sh(leg, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'caller' in r.stdout and "another checkout's graph" in r.stderr and 're-pointed at its own graph' in r.stderr + and 'interrupted' not in r.stderr and os.readlink(lptr) == 'typescript/graph.sqlite' and os.path.getmtime(logp) == logm, + f'copied checkout: an absolute pointer into another checkout ({case}) is re-pointed at this one\'s graph, said as such, with no rebuild', + f"{os.readlink(lptr)}\n" + r.stdout[-400:] + r.stderr[-600:]) + # control: an absolute pointer into this checkout's own out/ is its graph, re-pointed without a word + os.remove(lptr); os.symlink(os.path.join(os.path.realpath(leg), '.axiomcode', 'out', 'typescript', 'graph.sqlite'), lptr) + r = sh(leg, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'caller' in r.stdout and 'another checkout' not in r.stderr and os.readlink(lptr) == 'typescript/graph.sqlite', + 'control: an older absolute pointer into its own graph is kept, relative, silently', r.stderr[-400:]) + # ── gitignore ───────────────────────────────────────────────────────────────────────────────────────── gen = {f'gen/deep/g{i}.ts': f'export function gen{i}(): number {{\n return {i}\n}}\n' for i in range(200)} gi = repo_with(work, 'gitignore', dict({'tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', 'src/util.ts': UTIL, From 47e9dfdd0bb717bb4635518a6e90c26c68147af0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:10:33 -0700 Subject: [PATCH 10/37] tests: each engine suite runs its cases concurrently, reported in case order Every suite was one serial loop over cases/*/, and a case is a separate program whose few seconds are almost all per-process start-up (node, python, the solver), so a 4-CPU runner sat three-quarters idle: the TypeScript loop alone was 6.5 minutes of 79 cases at 4-7 s. graph/test/tools/case-pool.sh runs up to AXIOM_SUITE_JOBS cases at once (default: the CPU count). Each case's stdout and stderr go to its own file and are printed whole, in case order, as soon as every case before it has finished, and the counters the loop body updates (pass/fail and the failed list, under whatever names the suite uses) are summed in case order, so the report, the summary and the exit status are what the serial loop printed. The loop bodies are unchanged: each is wrapped in a one-case `for` so its `continue`s still mean "next case". AXIOM_SUITE_JOBS=1 is the old loop verbatim, nothing captured. --bless, --keep, --oracle and name filters behave as before; a body that calls `exit` still ends the suite with its status; an interrupt stops every running case. What concurrent cases must not share: * the compiled engine: the first case past the filter runs alone, so it compiles or restores the engine once and every later case finds it cached; * staged library facts: run-souffle.sh keys them on module directory names and CSV size+mtime in whole seconds. Every case's library IR lives in a directory of the same name (`$w/libir`), so two cases parsed in the same second could share a key while holding different rows. Serially that cannot happen; side by side it can. Each concurrent case stages into its own AXIOM_LIBFACTS_CACHE, a new knob that moves only those facts (the engine binary stays in the shared cache). Everything else a case touches is its own work directory; the oracles and normalisers write to stdout or into it, and the java agreement classes and C# engine are built before the loop or cached by atomic rename. TypeScript: the seven fixtures (linking, dispatch, typeflow, overloads, specificity, tsconfig-chain, multi-program) were the minute after the last case before the summary. They are now jobs in the same pool, each in its own $WORK- as before, so they run beside the cases. The pass/fail counters are also initialised before the source-root check that adds to them; they were set after it, so its failure was counted and then zeroed. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/pipeline/run-souffle.sh | 7 +- graph/test/csharp/run-tests.sh | 12 +- graph/test/java/run-tests.sh | 12 +- graph/test/javascript/run-tests.sh | 12 +- graph/test/python/run-tests.sh | 12 +- graph/test/tools/case-pool.sh | 165 ++++++++++++++++++++++++ graph/test/typescript/run-tests.sh | 193 ++++++++++++++++------------- 7 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 graph/test/tools/case-pool.sh diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 2b0cb5424..65d7f4a79 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -424,7 +424,12 @@ case "$LIBKEY" in *) echo "library cache key is not a digest (got '$LIBKEY') — refusing to reuse staged facts" >&2 exit 1;; esac -LIBDIR="$CACHE_ROOT/libfacts-$LIBKEY" +# AXIOM_LIBFACTS_CACHE moves ONLY the staged library facts (the compiled engine stays in +# CACHE_ROOT). The key is module names plus CSV size and mtime in whole seconds, which is +# enough for one run at a time; a test harness running cases side by side gives each its own +# directory, because two library IRs parsed in the same second into same-named directories +# can share a key (graph/test/tools/case-pool.sh). +LIBDIR="${AXIOM_LIBFACTS_CACHE:-$CACHE_ROOT}/libfacts-$LIBKEY" # How many IR modules the roots actually hold. Cheap (lib_modules is a marker test and a # one-level glob, never a find) and worth knowing before the message below: "this is the # 2GB read" was printed verbatim for a run whose --library directory was EMPTY, which is diff --git a/graph/test/csharp/run-tests.sh b/graph/test/csharp/run-tests.sh index ed6448a7e..fd6354543 100755 --- a/graph/test/csharp/run-tests.sh +++ b/graph/test/csharp/run-tests.sh @@ -102,8 +102,14 @@ if ! python3 "$HERE/corpus/aggregate-selftest.py"; then fi echo +# shellcheck source=../tools/case-pool.sh +. "$REPO/graph/test/tools/case-pool.sh" PASS=0; FAIL=0; FAILED="" -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" [ -d "$dir/src" ] || continue if [ -n "$ONLY" ]; then case ",$ONLY," in *,"$name",*) ;; *) continue ;; esac; fi @@ -170,6 +176,10 @@ EOF2 FAIL=$((FAIL+1)); FAILED="$FAILED $name" fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="PASS FAIL" POOL_STRINGS="FAILED" +pool_run case_body "$HERE"/cases/*/ echo echo "cases: $PASS passed, $FAIL failed" diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index b2556b431..53da26e36 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -157,6 +157,8 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" . "$HERE/tools/oracle-build.sh" ORACLE_FLAGS="" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; NO_TORTURE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; --no-torture) NO_TORTURE=1;; @@ -245,7 +247,11 @@ EMPTY_LIB="$WORK/.empty-library"; mkdir -p "$EMPTY_LIB"; LIB_ARG="$EMPTY_LIB" pass=0; fail=0; failed=() -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -592,6 +598,10 @@ PYEOF fail=$((fail+1)); failed+=("$name") fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ [ "$KEEP" = "1" ] || rm -rf "$WORK" # ── TORTURE: ten families of construct, scored per family ───────────────────────────────────── # The cases above each pin ONE rule. This asks what happens when a project uses everything at diff --git a/graph/test/javascript/run-tests.sh b/graph/test/javascript/run-tests.sh index e8f7b0967..61d7ee912 100755 --- a/graph/test/javascript/run-tests.sh +++ b/graph/test/javascript/run-tests.sh @@ -33,6 +33,8 @@ HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../../.." && pwd)" PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; @@ -76,7 +78,11 @@ oracle_check() { check_golden "$w/actual.oracle" "$golden" "oracle golden" } -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -124,6 +130,10 @@ for dir in "$HERE"/cases/*/; do if [ $ok = 1 ]; then echo "ok"; pass=$((pass+1)); else fail=$((fail+1)); failed+=("$name"); fi [ "$KEEP" = "1" ] || rm -rf "$w" done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ echo; echo "passed: $pass failed: $fail" [ $fail -eq 0 ] || { printf ' %s\n' "${failed[@]}"; exit 1; } # bin/axiomcode --library staging: every source-tree entry gets its own intermediate diff --git a/graph/test/python/run-tests.sh b/graph/test/python/run-tests.sh index 04189cff8..2e8801b50 100755 --- a/graph/test/python/run-tests.sh +++ b/graph/test/python/run-tests.sh @@ -92,6 +92,8 @@ PINNED_PY="$PY" GUARD_PY="$PINNED_PY" command -v "$GUARD_PY" >/dev/null 2>&1 || GUARD_PY="$PY" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" export AXIOM_PY_ORACLE="$ORACLE_HOME" BLESS=0; KEEP=0; ORACLE=0; ORACLE_ONLY=0; FILTERS=() @@ -266,7 +268,11 @@ fi pass=0; fail=0; failed=() -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" [ -d "$dir/src" ] || continue if [ ${#FILTERS[@]} -gt 0 ]; then @@ -412,6 +418,10 @@ for dir in "$HERE"/cases/*/; do fail=$((fail+1)); failed+=("$name") fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ # ── THE WHOLE-PROJECT FIXTURES ─────────────────────────────────────────────── # test/python/projects holds two realistic projects, 29 files and ~1,040 lines, which diff --git a/graph/test/tools/case-pool.sh b/graph/test/tools/case-pool.sh new file mode 100644 index 000000000..348b5ffc6 --- /dev/null +++ b/graph/test/tools/case-pool.sh @@ -0,0 +1,165 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Run a suite's per-case loop CONCURRENTLY, and report it exactly as the serial loop did. +# Sourced, not executed. +# +# Every engine suite is one loop over cases/*/, and every case is a separate program: its +# own parse, its own solve, its own oracle, its own work directory. Nearly all of a case's +# few seconds is fixed per-process cost (node, python, the solver starting up), so on a +# machine with several CPUs the serial loop left all but one idle. This runs up to +# AXIOM_SUITE_JOBS cases at a time and prints them as the loop would have: +# +# * each case's output (stdout and stderr) is captured to its own file and printed IN CASE +# ORDER, whole, as soon as every case before it has finished — the diagnostics of a +# failing case read exactly as they did; +# * the counters the loop body updates (pass/fail and the failed-case list, whatever the +# suite calls them) are summed in case order, so the summary and the failed list match. +# +# The loop body is not rewritten. A suite wraps it, unchanged, in a one-case `for` so its +# `continue`s keep working: +# +# case_body() { for dir in "$@"; do done; } +# POOL_INTS="pass fail" POOL_ARRAYS="failed" # (and/or POOL_STRINGS for "$X $name" lists) +# pool_run case_body "$HERE"/cases/*/ +# +# WHAT IS NOT SHARED BETWEEN CONCURRENT CASES +# * the compiled engine: the FIRST case that gets past the suite's filter runs alone, so +# it compiles (or restores) the engine once; every later case finds it cached. A compile +# racing another is still safe (run-souffle.sh builds to a temp name and renames), only +# wasteful, and this keeps it from happening. +# * the staged library facts: run-souffle.sh caches them under a key made of each module's +# directory NAME and its CSVs' size and mtime (seconds). Two cases whose library IR sits +# in a directory of the same name (`$w/libir`) and was parsed in the same second can +# share a key while holding different rows — impossible when cases run one after +# another, likely when they run side by side. Each concurrent job therefore stages into +# its own AXIOM_LIBFACTS_CACHE, so no key is ever shared between them. +# +# AXIOM_SUITE_JOBS=N at most N cases at once. Default: the number of CPUs. +# AXIOM_SUITE_JOBS=1 the old loop, verbatim: every case runs in this shell, nothing is +# captured, nothing is reordered. Use it to debug a case. +# ───────────────────────────────────────────────────────────────────────────── + +pool_cpus() { + local n + n="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)" + case "$n" in ''|*[!0-9]*|0) n=1;; esac + printf '%s\n' "$n" +} + +# The concurrency this run will use; suites print it so a log says how it was produced. +pool_jobs() { + local j="${AXIOM_SUITE_JOBS:-$(pool_cpus)}" + case "$j" in ''|*[!0-9]*|0) j=1;; esac + printf '%s\n' "$j" +} + +# pool_run ... runs for every arg; see the header. +pool_run() { + local _fn="$1"; shift + local _jobs; _jobs="$(pool_jobs)" + if [ "$_jobs" -le 1 ] || [ "$#" -le 1 ]; then + local _a + for _a in "$@"; do "$_fn" "$_a"; done + return 0 + fi + + _POOL_TMP="$(mktemp -d "${TMPDIR:-/tmp}/suite-pool.XXXXXX")" || { echo "pool: no temp dir" >&2; return 1; } + _POOL_N=$#; _POOL_NEXT=1; _POOL_PIDS="" + local _i=0 _a _warm=1 + # Background jobs of a non-interactive shell ignore SIGINT, so an interrupted suite would + # leave its cases running. Stop them, and leave the caller's traps as they were after. + trap '_pool_abort 130' INT TERM + + for _a in "$@"; do + _i=$((_i+1)) + _pool_launch "$_fn" "$_i" "$_a" + if [ "$_warm" = 1 ]; then + # One at a time until a case has actually run (produced output): that case pays the + # engine compile, if there is one, before anything runs beside it. + while [ ! -f "$_POOL_TMP/$_i.done" ]; do sleep 0.1; done + [ -s "$_POOL_TMP/$_i.out" ] && _warm=0 + _pool_flush + continue + fi + while [ $(( _i - $(_pool_finished) )) -ge "$_jobs" ]; do + _pool_flush; sleep 0.1 + done + _pool_flush + done + while [ "$_POOL_NEXT" -le "$_POOL_N" ]; do _pool_flush; [ "$_POOL_NEXT" -le "$_POOL_N" ] && sleep 0.1; done + wait + trap - INT TERM + rm -rf "$_POOL_TMP" + return 0 +} + +# One case in a background subshell: the counters start at zero there, and whatever the +# body added is written out for _pool_merge. `.done` is written last, after the subshell has +# exited, so a case whose body called `exit` is still noticed (no `.res`). +_pool_launch() { + local fn="$1" i="$2" arg="$3" d="$_POOL_TMP" + { + ( + export AXIOM_LIBFACTS_CACHE="$d/$i.libfacts" + local v + for v in ${POOL_INTS:-}; do eval "$v=0"; done + for v in ${POOL_STRINGS:-}; do eval "$v="; done + for v in ${POOL_ARRAYS:-}; do eval "$v=()"; done + "$fn" "$arg" + { + for v in ${POOL_INTS:-} ${POOL_STRINGS:-}; do eval "printf 'S\t%s\t%s\n' \"\$v\" \"\${$v}\""; done + for v in ${POOL_ARRAYS:-}; do + eval "for _e in \${$v[@]+\"\${$v[@]}\"}; do printf 'A\t%s\t%s\n' \"\$v\" \"\$_e\"; done" + done + } > "$d/$i.res.tmp" && mv -f "$d/$i.res.tmp" "$d/$i.res" + ) > "$d/$i.out" 2>&1 + echo "$?" > "$d/$i.rc" + rm -rf "$d/$i.libfacts" + : > "$d/$i.done" + } & + _POOL_PIDS="${_POOL_PIDS:-} $!" +} + +# How many launched cases have finished (counted by their `.done` marker, not by `jobs`, +# whose view from inside a command substitution differs between bash versions). +_pool_finished() { + local f n=0 + for f in "$_POOL_TMP"/*.done; do [ -e "$f" ] && n=$((n+1)); done + printf '%s\n' "$n" +} + +# Print, in order, every finished case whose predecessors have all been printed. +_pool_flush() { + local d="$_POOL_TMP" kind var val v + while [ "$_POOL_NEXT" -le "$_POOL_N" ] && [ -f "$d/$_POOL_NEXT.done" ]; do + cat "$d/$_POOL_NEXT.out" + if [ ! -f "$d/$_POOL_NEXT.res" ]; then + # The body exited instead of finishing. Serially that ended the suite with its + # status, so it does here too. + _pool_abort "$(cat "$d/$_POOL_NEXT.rc" 2>/dev/null || echo 1)" + fi + while IFS=$'\t' read -r kind var val; do + [ "$kind" = A ] && { eval "$var+=(\"\$val\")"; continue; } + for v in ${POOL_INTS:-}; do + [ "$v" = "$var" ] && { eval "$var=\$(( $var + $val ))"; continue 2; } + done + eval "$var=\"\${$var}\$val\"" + done < "$d/$_POOL_NEXT.res" + _POOL_NEXT=$((_POOL_NEXT+1)) + done +} + +# A case is a tree of processes (the subshell, then bash, node, the solver), so stop the tree. +_pool_killtree() { + local c + for c in $(pgrep -P "$1" 2>/dev/null); do _pool_killtree "$c"; done + kill "$1" 2>/dev/null +} + +_pool_abort() { + local p + for p in ${_POOL_PIDS:-}; do _pool_killtree "$p"; done + wait 2>/dev/null + trap - INT TERM + rm -rf "${_POOL_TMP:-/nonexistent-pool}" + exit "${1:-1}" +} diff --git a/graph/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh index c3af71d29..8cdc6b024 100755 --- a/graph/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -215,6 +215,9 @@ fi # A `.source-root` the reader cannot resolve keys every staged declaration under a path # nothing else names, and the empty join that follows is reported as a plausible rate # over the client's own files rather than as a failure (#342). +# (The counters start here, before the first check that adds to them: they were initialised +# after it, so a failure here was counted and then reset to zero.) +pass=0; fail=0; failed=() if ! python3 "$HERE/tools/source_root_test.py"; then echo "source-root: FAILED" fail=$((fail+1)); failed+=("source-root") @@ -244,6 +247,8 @@ if ! bash "$HERE/tools/envelope-merge-test.sh"; then fi PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; @@ -283,8 +288,6 @@ mkdir -p "$WORK" # library that declares nothing, so no client->lib edge can exist. EMPTY_LIB="$WORK/.empty-library"; mkdir -p "$EMPTY_LIB" -pass=0; fail=0; failed=() - # solve ; leaves edges in /out solve() { bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language typescript \ @@ -306,7 +309,11 @@ check_golden() { echo "FAIL ($label changed)"; diff -u "$exp" "$actual" | sed 's/^/ /' | head -40; return 1 } -for dir in "$HERE"/cases/*/; do +# ── ONE CASE ──────────────────────────────────────────────────────────────── +# The body of what was the case loop, unchanged, inside a one-case `for` so its `continue`s +# still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several at once. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -478,94 +485,110 @@ for dir in "$HERE"/cases/*/; do n2=0; [ "$HAS_LIB" = "1" ] && n2=$(wc -l < "$w/actual.lib.edges" | tr -d ' ') echo "ok (${n1} edges, ${n2} with lib)${orc}"; pass=$((pass+1)) done +} -# ── the LINKING gate ───────────────────────────────────────────────────────── -# Run here rather than left to be remembered. The golden cases above parse a case's -# own `lib/` directory; they never install a PACKAGE, so nothing in them exercises -# module resolution, staging discovery, or a non-flat node_modules — the whole -# client->library boundary. That gate lived in fixtures/linking/run.sh and was not -# invoked by anything, so this suite could report 20/20 green while every linking -# mechanism was broken. A gate nobody runs is not a gate. -# +# Asked once, up front, and passed explicitly. The fixture's own default used to be an +# absolute path inside one developer's home directory and this call never passed the +# argument, so anywhere else `typescript/lib` was never staged, the global scope was +# empty, and the MANDATORY baseline failed with three assertions that read as engine +# defects in native resolution rather than as a missing argument (#331). +NODE_MODULES="" +[ "$BLESS" = "1" ] || NODE_MODULES="$(bash "$HERE/tools/find-node-modules.sh" || true)" + +# ── ONE FIXTURE ───────────────────────────────────────────────────────────── +# Each fixture is a job like a case: its own work directory ($WORK-) and log, so +# they run beside the cases and each other, and print in the order below. +fixture_job() { + local fx="$1" rc + case "$fx" in + linking) + echo + [ -n "$NODE_MODULES" ] || echo " ! no node_modules with a typescript found — fixtures will say so" + echo "── linking fixture ──" + if bash "$HERE/fixtures/linking/run.sh" "${WORK:-/tmp/ts-linking-fixture}-linking" "$NODE_MODULES" >"$WORK-linking.log" 2>&1; then + echo "linking fixture: ok" + else + echo "linking fixture: FAILED" + grep -E '^FAIL' "$WORK-linking.log" | sed 's/^/ /' || tail -5 "$WORK-linking.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("linking-fixture") + fi;; + dispatch|typeflow|overloads|specificity) + echo + echo "── $fx fixture ──" + bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$NODE_MODULES" >"$WORK-$fx.log" 2>&1 + rc=$? + if [ "$rc" -eq 0 ]; then + echo "$fx fixture: ok" + elif [ "$rc" -eq 77 ]; then + echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" + else + echo "$fx fixture: FAILED" + grep -E '^FAIL|^ *!' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("$fx-fixture") + fi;; + tsconfig-chain|multi-program) + echo + echo "── $fx fixture ──" + bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$PARSER" \ + >"$WORK-$fx.log" 2>&1 + rc=$? + if [ "$rc" -eq 0 ]; then + echo "$fx fixture: ok" + elif [ "$rc" -eq 77 ]; then + echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" + else + echo "$fx fixture: FAILED" + grep -E '^FAIL' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("$fx-fixture") + fi;; + esac +} + +suite_job() { case "$1" in fixture:*) fixture_job "${1#fixture:}";; *) case_body "$1";; esac; } + +# ── THE FIXTURES ──────────────────────────────────────────────────────────── +# `linking`: the golden cases parse a case's own `lib/` directory; they never install a +# PACKAGE, so nothing in them exercises module resolution, staging discovery, or a +# non-flat node_modules — the whole client->library boundary. That gate lived in +# fixtures/linking/run.sh and was not invoked by anything, so this suite could report +# 20/20 green while every linking mechanism was broken. A gate nobody runs is not a gate. # Skipped, loudly, when the fixture cannot build (it needs a real `typescript` to # symlink); never silently passed. +# +# THE FOUR GATES NOTHING RAN. The same reasoning was acted on for `linking` and these four +# were left, so the suite could report green while any of the four mechanisms was broken. +# All four pass today and take about six seconds each — they are unrun, not rotten (#332). +# Each isolates a question the per-case goldens cannot ask: +# dispatch an interface-typed receiver, where the compiler names the SIGNATURE +# and the engine emits the reachable BODIES — two different right +# answers, deliberately kept apart +# typeflow every way a receiver acquires a type other than being annotated, +# with member names shared on purpose so a lucky name match cannot pass +# overloads one overload set reached through a barrel re-export and by direct +# import, so a difference between the two consumers is the module graph +# rather than the overload logic +# specificity generic-first overload sets in both directions, built because the +# obvious fix for the largest corpus failure class is wrong +# Their second argument is a node_modules to stage a `typescript` from, discovered rather +# than defaulted to one developer's home directory (#331). Empty means the fixture falls +# back to its own discovery and says so. +# +# tsconfig-chain and multi-program are harness gates that take a parser and assert a +# property of the pipeline itself. Neither is expressible as a case: a case carries its own +# src/tsconfig.json with nothing to extend (#240), and none installs a package with several +# programs (#230). Exit 77 is a fixture declining for want of a parser — not a pass and not +# a failure. +# +# Not under --bless: they have no goldens to regenerate. +JOBS=("$HERE"/cases/*/) if [ "$BLESS" != "1" ]; then - echo - # Asked once, here, and passed explicitly. The fixture's own default used to be an - # absolute path inside one developer's home directory and this call never passed the - # argument, so anywhere else `typescript/lib` was never staged, the global scope was - # empty, and the MANDATORY baseline failed with three assertions that read as engine - # defects in native resolution rather than as a missing argument (#331). - NODE_MODULES="$(bash "$HERE/tools/find-node-modules.sh" || true)" - [ -n "$NODE_MODULES" ] || echo " ! no node_modules with a typescript found — fixtures will say so" - - echo "── linking fixture ──" - if bash "$HERE/fixtures/linking/run.sh" "${WORK:-/tmp/ts-linking-fixture}-linking" "$NODE_MODULES" >"$WORK-linking.log" 2>&1; then - echo "linking fixture: ok" - else - echo "linking fixture: FAILED" - grep -E '^FAIL' "$WORK-linking.log" | sed 's/^/ /' || tail -5 "$WORK-linking.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("linking-fixture") - fi - - # ── THE FOUR GATES NOTHING RAN ────────────────────────────────────────────── - # The comment above says it for the fifth: a gate nobody runs is not a gate. That - # reasoning was acted on for `linking` and these four were left, so the suite could - # report green while any of the four mechanisms was broken. All four pass today and - # take about six seconds each — they are unrun, not rotten (#332). - # - # Each isolates a question the per-case goldens cannot ask: - # dispatch an interface-typed receiver, where the compiler names the SIGNATURE - # and the engine emits the reachable BODIES — two different right - # answers, deliberately kept apart - # typeflow every way a receiver acquires a type other than being annotated, - # with member names shared on purpose so a lucky name match cannot pass - # overloads one overload set reached through a barrel re-export and by direct - # import, so a difference between the two consumers is the module graph - # rather than the overload logic - # specificity generic-first overload sets in both directions, built because the - # obvious fix for the largest corpus failure class is wrong - # - # Second argument is a node_modules to stage a `typescript` from, discovered rather - # than defaulted to one developer's home directory (#331). Empty means the fixture - # falls back to its own discovery and says so. - for fx in dispatch typeflow overloads specificity; do - echo - echo "── $fx fixture ──" - bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$NODE_MODULES" >"$WORK-$fx.log" 2>&1 - rc=$? - if [ "$rc" -eq 0 ]; then - echo "$fx fixture: ok" - elif [ "$rc" -eq 77 ]; then - echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" - else - echo "$fx fixture: FAILED" - grep -E '^FAIL|^ *!' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("$fx-fixture") - fi - done - - # Harness gates that take a parser and assert a property of the pipeline itself. - # Neither is expressible as a case: a case carries its own src/tsconfig.json with - # nothing to extend (#240), and none installs a package with several programs (#230). - # Exit 77 is a fixture declining for want of a parser — not a pass and not a failure. - for fx in tsconfig-chain multi-program; do - echo - echo "── $fx fixture ──" - bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$PARSER" \ - >"$WORK-$fx.log" 2>&1 - rc=$? - if [ "$rc" -eq 0 ]; then - echo "$fx fixture: ok" - elif [ "$rc" -eq 77 ]; then - echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" - else - echo "$fx fixture: FAILED" - grep -E '^FAIL' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("$fx-fixture") - fi + for fx in linking dispatch typeflow overloads specificity tsconfig-chain multi-program; do + JOBS+=("fixture:$fx") done fi +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run suite_job "${JOBS[@]}" [ "$KEEP" = "1" ] || rm -rf "$WORK" echo From b9c81091eeaa881736c406bf27d59c40a5809478 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:10:42 -0700 Subject: [PATCH 11/37] ci: a portable compiled engine, cached without the runner's CPU, and saved by release branches The engine cache key carried a hash of the runner's CPU model and feature flags, because the driver compiles with -march=native and a binary restored onto a different hosted runner died with SIGILL. Which CPU a job lands on is luck, so the key missed on nearly every run and each language paid a three-minute compile for rules nothing had changed. run-souffle.sh takes AXIOM_ENGINE_MARCH: `native` stays the default, `portable` compiles for the compiler's baseline target, as build-engines.yml builds the published engines; any other value is passed as -march=. The engine job sets `portable`, so any runner can run any runner's binary, and the key names the setting instead of the CPU. The SIGILL guard stays for the native default. A cache saved by a pull request is restorable by that pull request alone; the ones every pull request can restore are its base branch's and the default branch's. Pull requests target release branches (0.1.6, ...), which CI never ran on, so nothing was ever saved where the next pull request could find it. A push to a `0.*` branch now runs the suites (not the platform engine build), which saves the engine for that branch's rules. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++------- graph/pipeline/run-souffle.sh | 17 ++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a59ce1fd6..280a8462c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,11 @@ name: CI on: push: - branches: [main, dev] # dev takes direct pushes, so they get a result too + # dev takes direct pushes, so they get a result too. A release branch (0.1.6, ...) + # takes merges from pull requests: the run on the merge is what saves the compiled + # engines where the NEXT pull request into it can restore them — a cache a pull + # request saves is visible to that pull request alone. + branches: [main, dev, '0.*'] pull_request: merge_group: workflow_dispatch: @@ -78,6 +82,10 @@ jobs: echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" echo "push to dev: suites run, platform engines wait for main"; exit 0 fi + if [ "${{ github.event_name }}" = push ] && [[ "${{ github.ref }}" == refs/heads/0.* ]]; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" + echo "push to a release branch: suites run (and save its engines for pull requests into it), platform engines wait for main"; exit 0 + fi # A push to main is a release when its version has no tag yet: that commit, and only that one, # builds every platform and runs the five-platform e2e, and release.yml drafts from it once # it is green. A push whose version is already tagged released nothing new and builds nothing. @@ -246,6 +254,10 @@ jobs: if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 + env: + # the engine is compiled for any x86-64 runner, so a cached one can be restored + # on whichever runner this job lands (see the engine cache step below) + AXIOM_ENGINE_MARCH: portable strategy: fail-fast: false matrix: @@ -396,23 +408,34 @@ jobs: # one language recompiles that language only, and an unchanged one never does. # Two things decide the binary that ENGINE_ID does not cover, so they are in the # key too: the driver that compiles it (run-souffle.sh holds the compiler flags), - # and the runner's CPU, because the driver compiles with -march=native and a - # binary built on one CPU can die with an illegal instruction on another. - # The CPU part hashes the FEATURE FLAGS, not only the model name: hosted runners - # that report the same model name do not all expose the same instruction set, so - # a model-name key restored a binary that died with SIGILL on every case. + # and the target it is compiled for. + # + # THE TARGET IS PORTABLE, NOT THE RUNNER'S CPU. The driver's default is + # -march=native, and a binary built on one hosted runner died with SIGILL on + # another (runners that report the same model name do not all expose the same + # instruction set). The key used to carry a hash of the runner's CPU flags to + # keep such a binary off other CPUs — and so it missed on almost every run, + # since which CPU a job lands on is luck: a three-minute compile per language, + # per run, for rules nothing had changed. AXIOM_ENGINE_MARCH=portable compiles + # for the compiler's baseline x86-64 target instead, as the published engines + # are, so any runner can run any runner's binary and the CPU leaves the key. + # + # A cache saved by a pull request is visible to that pull request only. The + # ones every pull request can restore are the base branch's and the default + # branch's, which is why a push to a release branch runs the suites too (see + # `on.push` and the `changes` job): it leaves the engine for its rules where + # every pull request into that branch finds it. - name: this language's engine id id: eid run: | echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" - echo "cpu=$(grep -E '^(model name|flags)[[:space:]]*:' /proc/cpuinfo | sort -u | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT" - name: cache the compiled Soufflé engine if: ${{ !inputs.fresh }} uses: actions/cache@v4 with: path: .souffle-cache - key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-${{ steps.eid.outputs.cpu }} + key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} - name: ${{ matrix.lang }} regression suite env: @@ -420,6 +443,8 @@ jobs: # The driver's default cache is ~/.cache/axiomcode/souffle; point it at the # directory the cache step above saves and restores, or it never hits. AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + # The cases run concurrently, up to one per CPU (graph/test/tools/case-pool.sh); + # AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to. run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} # Every language's engine, every platform, built once per release: on the push that diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 65d7f4a79..79c797220 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -536,6 +536,16 @@ echo "▶ engine id = $ENGINE_ID (rules + souffle $SOUFFLE_VERSION)" # per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is # gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. CACHE_DIR="$CACHE_ROOT" +# -march: `native` by default, tuned for the machine that compiles and runs it. A binary +# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs +# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline +# target instead, as the published engines are (build-engines.yml). Any other value is +# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a +# cache across machines keys it on the setting (ci.yml does). +case "${AXIOM_ENGINE_MARCH:-native}" in + portable) MARCH_FLAG=();; + *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; +esac EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" @@ -611,7 +621,7 @@ elif command -v souffle >/dev/null 2>&1; then [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } CXX_PLATFORM="" case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 -march=native -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 fi # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may @@ -698,13 +708,14 @@ while [ "$iter" -lt 50 ]; do INNER="${INNER:-$(find_souffle_include)}" souffle -I "$SRC" -g "$INT/profile-program.cpp" -p "$AXIOM_SOUFFLE_PROFILE" "$PROG" \ 2> "$INT/.souffle-prof-gen.log" || { cat "$INT/.souffle-prof-gen.log" >&2; exit 1; } - c++ -std=c++17 -O3 -march=native -w -I "$INNER" \ + c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w -I "$INNER" \ "$INT/profile-program.cpp" -o "$PBIN" || exit 1 fi echo "▶ solving with profiling -> $AXIOM_SOUFFLE_PROFILE" "$PBIN" -F "$FACTS" -D "$RAW" -p "$AXIOM_SOUFFLE_PROFILE" else - # A cached binary is compiled with -march=native. Restored onto a CPU without one of + # A cached binary is compiled with -march=native (unless AXIOM_ENGINE_MARCH says + # otherwise). Restored onto a CPU without one of # the instructions it uses (a shared cache, a CI cache keyed too coarsely), it dies # with SIGILL (exit 132) before solving anything. Never leave it there to kill every # later run the same way: drop the cache entry, so the next run recompiles, and say so. From f6b9da609ad52dff041bd2731189de7ce78bf5fb Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:46:12 -0700 Subject: [PATCH 12/37] ci: save the compiled engine even when the suite fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/cache saves only after a successful job, so a pull request that was red — the one whose next push most needs to be fast — recompiled its engine on every push, and a release branch whose suite was red saved nothing for the pull requests into it. The binary does not depend on the verdict: run-souffle.sh publishes it only whole and verified. The cache step is now a restore before the suite and a save after it that runs whatever the result, except when the job was cancelled or this language's engine is not there to save (a key saved without it would stay taken and could never be filled). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 280a8462c..776ce8d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,9 +430,10 @@ jobs: run: | echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" - - name: cache the compiled Soufflé engine + - name: restore the compiled Soufflé engine + id: engine-cache if: ${{ !inputs.fresh }} - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: .souffle-cache key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} @@ -447,6 +448,21 @@ jobs: # AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to. run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} + # Saved whether or not the suite passed. The binary does not depend on the verdict: + # run-souffle.sh publishes it only whole and verified (a temp name, then a rename), + # so a red run's engine is as good as a green one's, and the run that most needs the + # next push to be fast is the red one. Not when cancelled, and not unless this + # language's engine is actually there: a key saved without it would stay taken, and + # every later run would restore the gap and could never save over it. + - name: save the compiled Soufflé engine + if: >- + ${{ !cancelled() && !inputs.fresh && steps.engine-cache.outputs.cache-hit != 'true' + && hashFiles(format('.souffle-cache/souffle-engine-{0}-{1}', matrix.lang, steps.eid.outputs.id)) != '' }} + uses: actions/cache/save@v4 + with: + path: .souffle-cache + key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} + # Every language's engine, every platform, built once per release: on the push that # lands a new version on main (and in the nightly). publish-npm ships these very # artifacts, so what the e2e below tested is what users install (#1350). From 06e8b757ce01f0b41d6d0466f589ab73603d5ce5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:00 -0700 Subject: [PATCH 13/37] tests: each suite's header names AXIOM_SUITE_JOBS Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/test/java/run-tests.sh | 1 + graph/test/javascript/run-tests.sh | 1 + graph/test/python/run-tests.sh | 1 + graph/test/typescript/run-tests.sh | 1 + 4 files changed, 4 insertions(+) diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index 53da26e36..964ecb5ec 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -39,6 +39,7 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # # NO EXTERNAL LIBRARY IR IS USED OR REQUIRED — see the note above the EMPTY_LIB line. A case # may however ship its own lib-src/ STUB library (kilobytes, in the repo), which is extracted diff --git a/graph/test/javascript/run-tests.sh b/graph/test/javascript/run-tests.sh index 61d7ee912..ad3b83627 100755 --- a/graph/test/javascript/run-tests.sh +++ b/graph/test/javascript/run-tests.sh @@ -27,6 +27,7 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" diff --git a/graph/test/python/run-tests.sh b/graph/test/python/run-tests.sh index 2e8801b50..254ec5731 100755 --- a/graph/test/python/run-tests.sh +++ b/graph/test/python/run-tests.sh @@ -36,6 +36,7 @@ # # Environment: # AXIOM_PARSER parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # AXIOM_PY_ORACLE harness checkout (--oracle only; default ../../../callchain-oracle/python) # AXIOM_PY_PYTHON pinned interpreter (default python3.10; also used by the # tier-1 attribution preflight, which is version-sensitive) diff --git a/graph/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh index 8cdc6b024..ae67e28e8 100755 --- a/graph/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -32,6 +32,7 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" From f594bc2c4fe9deb51207b839eb4d8235e4031cd7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:36:46 -0700 Subject: [PATCH 14/37] javascript: path says unknown, not independent, when one side calls through a parameter, a loop variable, a local or a computed member (#1649) The engine marks an unresolved call whose callee is a value rather than a name (unresolved_value_callee: parameter, loop_variable, local, computed, dynamic_code). path reads it: when no chain and no by-name lead connects the two, a side whose closure makes such a call now answers that the connection is unknown and names the sites, instead of "independent". A call through an imported binding, a resolved call and a function that calls nothing keep their answers. No call edge changes. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 17 ++ graph/javascript/souffle/decls_all.dl | 2 + graph/javascript/souffle/export_manifest.tsv | 1 + .../cases/64-value-callee/src/handlers.js | 13 ++ .../javascript/expected/11-class-members.diag | 1 + .../javascript/expected/25-stream-hooks.diag | 6 + .../javascript/expected/27-parse-gaps.diag | 1 + .../expected/32-symbol-keyed-members.diag | 2 + .../expected/34-options-object-callbacks.diag | 1 + .../34-options-object-callbacks.lib.diag | 1 + .../expected/40-object-keys-strings.diag | 1 + .../expected/58-well-known-symbol.diag | 2 + .../61-dispatch-table-computed-key.diag | 1 + .../javascript/expected/64-value-callee.diag | 16 ++ .../javascript/expected/64-value-callee.edges | 10 ++ .../expected/64-value-callee.oracle | 2 + .../javascript/tools/normalize_diagnostics.py | 4 + .../skills/axiomcode/scripts/ax_pages.py | 5 + .../skills/axiomcode/scripts/axiomcode-path | 30 +++- .../value-callee-is-unknown/case.json | 170 ++++++++++++++++++ .../value-callee-is-unknown/src/handlers.js | 13 ++ 21 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 graph/test/javascript/cases/64-value-callee/src/handlers.js create mode 100644 graph/test/javascript/expected/64-value-callee.diag create mode 100644 graph/test/javascript/expected/64-value-callee.edges create mode 100644 graph/test/javascript/expected/64-value-callee.oracle create mode 100644 tests/cases/javascript/value-callee-is-unknown/case.json create mode 100644 tests/cases/javascript/value-callee-is-unknown/src/handlers.js diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index ec0a7b5ea..55697c0d0 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -71,3 +71,20 @@ unresolved_covered(ce) :- call_site("client", ck, _, rp, _, _, ce, _, _), rp != unresolved_covered(ce) :- call_site("client", ck, _, _, _, _, ce, _, _), call_kind_is_dynamic(ck). unresolved_receiver(ce, cn, ck, "other_form") :- call_site("client", ck, cn, _, _, _, ce, _, _), !call_resolved(ce), !unresolved_covered(ce). + +// ── unresolved_value_callee(CallExpr, Binding) ────────────────────────────── +// An unresolved call whose callee is a VALUE, not a name: `cb()` on a parameter, +// `f()` on a loop variable or a local, `table[k]()`, `eval(s)`. Nothing about its +// name narrows what it calls, so any function that escapes as a value may be the +// callee, and a question that finds no chain from its caller must answer "unknown", +// never "independent" (#1649). Binding: parameter | loop_variable | local | +// computed | dynamic_code. An imported binding is not one: it names a module +// member, and a module-level binding is left to the by-name leads. +value_callee_open(ce, c) :- call_site("client", ck, _, _, _, _, ce, _, _), !call_kind_is_dynamic(ck), + !call_resolved(ce), !callee_value_known(ce), expr_child(_, ce, "CALLEE", _, c). +unresolved_value_callee(ce, "parameter") :- value_callee_open(ce, c), expr_param(_, _, c). +unresolved_value_callee(ce, "loop_variable") :- value_callee_open(ce, c), expr_binding(_, v, c), loop_head(v, _). +unresolved_value_callee(ce, "local") :- value_callee_open(ce, c), expr_binding(_, v, c), !loop_head(v, _), + var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c). +unresolved_value_callee(ce, "computed") :- call_site("client", "COMPUTED_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). +unresolved_value_callee(ce, "dynamic_code") :- call_site("client", "DYNAMIC_CODE_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 184041a18..32ddecf4c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -70,6 +70,8 @@ .decl call_resolved(c0:symbol) .decl unresolved_receiver(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl unresolved_covered(c0:symbol) +.decl value_callee_open(c0:symbol, c1:symbol) +.decl unresolved_value_callee(c0:symbol, c1:symbol) // ── containment/ownership.dl ── .decl call_caller_method(c0:symbol, c1:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index b90de230f..1e8b5aac9 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -5,6 +5,7 @@ client_calls_lib client-to-lib-calls.csv client_calls_lib_detail client-to-lib-navigation.csv unresolved_receiver unresolved-receiver.csv method_has_unknown_call method-has-unknown-call.csv +unresolved_value_callee unresolved-value-callee.csv entry_point entry-point.csv entry_reachable entry-reachable.csv method_call_edge method-call-edge.csv diff --git a/graph/test/javascript/cases/64-value-callee/src/handlers.js b/graph/test/javascript/cases/64-value-callee/src/handlers.js new file mode 100644 index 000000000..9fa522427 --- /dev/null +++ b/graph/test/javascript/cases/64-value-callee/src/handlers.js @@ -0,0 +1,13 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +function runAll(handlers) { handlers.forEach(h => h()); } +function dispatch(table, k) { return table[k](); } +function invoke(cb) { return cb(); } +function runList(ctx) { for (const f of ctx.list) f(); } +function viaLocal(o) { const fn = o.pick; return fn(); } +function viaCall(cb) { return cb.call(null); } +function pure() { return 1 + 2; } +function joins() { return path.join('a', 'b'); } +function direct() { return alpha(); } +module.exports = { alpha, runAll, dispatch, invoke, runList, viaLocal, viaCall, pure, joins, direct }; diff --git a/graph/test/javascript/expected/11-class-members.diag b/graph/test/javascript/expected/11-class-members.diag index 3938fcda9..e80c1f58e 100644 --- a/graph/test/javascript/expected/11-class-members.diag +++ b/graph/test/javascript/expected/11-class-members.diag @@ -9,3 +9,4 @@ unresolved main.js:26:17 FUNCTION_CALL_BIND Holder no_target unresolved main.js:26:55 CONSTRUCTOR_CALL Bound callee_untyped unresolved main.js:26:55 METHOD_CALL go receiver_untyped unresolved main.js:7:24 FUNCTION_CALL String no_target +value_callee main.js:26:55 Bound local diff --git a/graph/test/javascript/expected/25-stream-hooks.diag b/graph/test/javascript/expected/25-stream-hooks.diag index 5cf6dc6a8..ca85b8d0c 100644 --- a/graph/test/javascript/expected/25-stream-hooks.diag +++ b/graph/test/javascript/expected/25-stream-hooks.diag @@ -21,3 +21,9 @@ unresolved streams.js:7:67 FUNCTION_CALL cb callee_untyped unresolved streams.js:7:99 FUNCTION_CALL cb callee_untyped unresolved streams.js:8:55 FUNCTION_CALL cb callee_untyped unresolved streams.js:8:73 METHOD_CALL push no_target +value_callee streams.js:5:120 cb parameter +value_callee streams.js:5:63 cb parameter +value_callee streams.js:5:92 cb parameter +value_callee streams.js:7:67 cb parameter +value_callee streams.js:7:99 cb parameter +value_callee streams.js:8:55 cb parameter diff --git a/graph/test/javascript/expected/27-parse-gaps.diag b/graph/test/javascript/expected/27-parse-gaps.diag index 39eff716c..18aec1be1 100644 --- a/graph/test/javascript/expected/27-parse-gaps.diag +++ b/graph/test/javascript/expected/27-parse-gaps.diag @@ -24,3 +24,4 @@ unresolved jsdoc.js:5:33 METHOD_CALL render receiver_type_unreadable unresolved jsdoc.js:5:33 METHOD_CALL render receiver_untyped unresolved jsdoc.js:8:30 METHOD_CALL render receiver_untyped unresolved partial.js:5:42 FUNCTION_CALL unknownThing callee_untyped +value_callee dynamic.js:3:29 eval dynamic_code diff --git a/graph/test/javascript/expected/32-symbol-keyed-members.diag b/graph/test/javascript/expected/32-symbol-keyed-members.diag index 604fb0bca..af10249bc 100644 --- a/graph/test/javascript/expected/32-symbol-keyed-members.diag +++ b/graph/test/javascript/expected/32-symbol-keyed-members.diag @@ -8,3 +8,5 @@ unresolved queue.js:4:15 FUNCTION_CALL Symbol no_target unresolved queue.js:5:17 METHOD_CALL for no_target unresolved symbols.js:2:28 FUNCTION_CALL Symbol no_target unresolved symbols.js:2:53 FUNCTION_CALL Symbol no_target +value_callee queue.js:33:54 q[Symbol] computed +value_callee queue.js:33:76 q[anySymbol] computed diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index 87834728c..f261bb398 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -10,3 +10,4 @@ unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target +value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag index 785706d9d..99d2f7ba0 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -8,3 +8,4 @@ unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target +value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/40-object-keys-strings.diag b/graph/test/javascript/expected/40-object-keys-strings.diag index 114e99542..e3e1b7004 100644 --- a/graph/test/javascript/expected/40-object-keys-strings.diag +++ b/graph/test/javascript/expected/40-object-keys-strings.diag @@ -20,3 +20,4 @@ unresolved main.js:26:42 METHOD_CALL toLowerCase no_target unresolved main.js:34:26 METHOD_CALL padStart no_target unresolved main.js:35:34 COMPUTED_CALL dynamic unresolved main.js:9:21 METHOD_CALL keys no_target +value_callee main.js:35:34 handlers[name] computed diff --git a/graph/test/javascript/expected/58-well-known-symbol.diag b/graph/test/javascript/expected/58-well-known-symbol.diag index 63a5cbad6..3588515bf 100644 --- a/graph/test/javascript/expected/58-well-known-symbol.diag +++ b/graph/test/javascript/expected/58-well-known-symbol.diag @@ -2,3 +2,5 @@ package_entry 58-well-known-symbol . [] DEFAULT_INDEX index.js MISSING_F unresolved main.js:15:14 FUNCTION_CALL Symbol no_target unresolved main.js:21:37 COMPUTED_CALL dynamic unresolved main.js:9:40 COMPUTED_CALL dynamic +value_callee main.js:21:37 b[Symbol[x]] computed +value_callee main.js:9:40 this.items[Symbol.iterator] computed diff --git a/graph/test/javascript/expected/61-dispatch-table-computed-key.diag b/graph/test/javascript/expected/61-dispatch-table-computed-key.diag index b42e3ce0d..cabfa5949 100644 --- a/graph/test/javascript/expected/61-dispatch-table-computed-key.diag +++ b/graph/test/javascript/expected/61-dispatch-table-computed-key.diag @@ -2,3 +2,4 @@ package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED unresolved main.js:10:23 FUNCTION_CALL_CALL hasOwnProperty no_target unresolved main.js:30:10 COMPUTED_CALL dynamic unresolved main.js:69:15 FUNCTION_CALL Number no_target +value_callee main.js:30:10 table[prop] computed diff --git a/graph/test/javascript/expected/64-value-callee.diag b/graph/test/javascript/expected/64-value-callee.diag new file mode 100644 index 000000000..d392b7a25 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.diag @@ -0,0 +1,16 @@ +import_cause handlers.js:2:14 path builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved handlers.js:11:27 METHOD_CALL join no_target +unresolved handlers.js:4:29 METHOD_CALL forEach receiver_untyped +unresolved handlers.js:4:51 FUNCTION_CALL h callee_untyped +unresolved handlers.js:5:38 COMPUTED_CALL dynamic +unresolved handlers.js:6:30 FUNCTION_CALL cb callee_untyped +unresolved handlers.js:7:51 FUNCTION_CALL f callee_untyped +unresolved handlers.js:8:50 FUNCTION_CALL fn callee_untyped +unresolved handlers.js:9:31 FUNCTION_CALL_CALL cb callee_untyped +value_callee handlers.js:4:51 h parameter +value_callee handlers.js:5:38 table[k] computed +value_callee handlers.js:6:30 cb parameter +value_callee handlers.js:7:51 f loop_variable +value_callee handlers.js:8:50 fn local +value_callee handlers.js:9:31 cb.call parameter diff --git a/graph/test/javascript/expected/64-value-callee.edges b/graph/test/javascript/expected/64-value-callee.edges new file mode 100644 index 000000000..2044afa30 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.edges @@ -0,0 +1,10 @@ +handlers.js:11:27 METHOD_CALL path.join -> ambient_terminal - +handlers.js:12:28 FUNCTION_CALL alpha -> known_edge handlers.js:3:1 alpha +handlers.js:4:29 METHOD_CALL handlers.forEach -> ambiguous_unknown - +handlers.js:4:29 METHOD_CALL handlers.forEach -> callback_registered handlers.js:4:46 +handlers.js:4:51 FUNCTION_CALL h -> ambiguous_unknown - +handlers.js:5:38 COMPUTED_CALL table[k] -> dynamic_terminal - +handlers.js:6:30 FUNCTION_CALL cb -> ambiguous_unknown - +handlers.js:7:51 FUNCTION_CALL f -> ambiguous_unknown - +handlers.js:8:50 FUNCTION_CALL fn -> ambiguous_unknown - +handlers.js:9:31 FUNCTION_CALL_CALL cb.call -> ambient_terminal - diff --git a/graph/test/javascript/expected/64-value-callee.oracle b/graph/test/javascript/expected/64-value-callee.oracle new file mode 100644 index 000000000..cc6bbd7a9 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.oracle @@ -0,0 +1,2 @@ +handlers.js:12:28 FUNCTION_CALL alpha EXACT handlers.js:3:1 +# defects: 0 diff --git a/graph/test/javascript/tools/normalize_diagnostics.py b/graph/test/javascript/tools/normalize_diagnostics.py index 4114a29d7..242a8bfd5 100644 --- a/graph/test/javascript/tools/normalize_diagnostics.py +++ b/graph/test/javascript/tools/normalize_diagnostics.py @@ -85,6 +85,10 @@ def raw(path, width): for f in raw(os.path.join(out, 'unresolved-receiver.csv'), 4): pos, kind, _ = site.get(f[0], ('?', f[2], '?')) lines.add('unresolved %s %s %s %s' % (pos, kind, f[1], f[3])) +# unresolved_value_callee(CallExpr, Binding): the callee is a value, so the site may call anything (#1649) +for f in raw(os.path.join(out, 'unresolved-value-callee.csv'), 2): + pos, _, text = site.get(f[0], ('?', '?', '?')) + lines.add('value_callee %s %s %s' % (pos, text, f[1])) # package_entry(Prov, Package, Subpath, Condition, Source, Target, Outcome, ModuleHash) for f in raw(os.path.join(out, 'package-entry.csv'), 8): lines.add('package_entry %s %s [%s] %s %s %s -> %s' % ( diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index a0d932d02..a809781fa 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -141,6 +141,11 @@ def next_path(text): return ("next: no chain of calls; the library calls named above are where one could continue: read the body " "that makes them — one that publishes, schedules or registers what the entered method handles connects " "the two at run time") + if 'connection is UNKNOWN, not absent' in text: + m = re.search(r'^\s+`[^`]*` in \S+ at (\S+:\d+)', text, re.M) + return ("next: not shown to be independent — " + (f"read {m.group(1)} and " if m else "read the calls through a value named above and ") + + "find what its callee is given (the arguments its callers pass, what the loop runs over); a function handed " + "there is the connection") if 'the hop above is the only connection' in text: return "next: the cross-process hop above is the only connection; `impact ` lists it as a [remote] dependent" # an endpoint a framework enters in a way the graph does not model: the check is the grep the verdict printed diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 404481d61..9c7659b82 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1353,6 +1353,24 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): print(f" {ly} is entered from outside the graph in the same process, as such a call can do: " + ', '.join(f"{g.disp(m)} ({why}) {g.loc(m)}" for m, why in entered[:3]) + (f" … +{len(entered) - 3}" if len(entered) > 3 else '')) + # A CALL WHOSE CALLEE IS A VALUE CAN LAND ANYWHERE (#1649). `cb()` on a parameter, `f()` on a loop variable, + # `table[k]()`: nothing about the name narrows the target, so the by-name search above finds no lead and + # "independent" was printed for a start that hands control to whatever it was given. The engine marks such a + # site (`unresolved_value_callee`); one in either side's closure makes the connection unknown, not absent. + opaque = [] + if not (remote_found or lib_side) and g.has('ext_unresolved_value_callee'): + for xs, lx in ((A_, la), (B_, lb)): + seen = {i for i in xs if i in g.sym and g.sym[i]['kind'] not in ('library', 'written')}; fr = list(seen) + while fr: + fr = [y for y in dict.fromkeys(y for x in fr for y in adj.get(x, ())) if y not in seen and y in g.sym + and g.sym[y]['kind'] not in ('library', 'written')] + seen.update(fr) + ids = sorted(seen); rows = [] + for i in range(0, len(ids), 900): + part = ids[i:i + 900] + rows += g.q("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" + " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)" % ','.join('?' * len(part)), *part) + if rows: opaque.append((lx, sorted(rows, key=lambda r: (g.site_file(r['f']), r['ln'] or 0)))) # …but a framework may still connect them, and the graph holds the evidence for it: the target is registered # under a key, and the source writes that key. That is not a chain of calls, so it is reported and not walked. # Computed BEFORE the verdict line, which used to say "independent" one line above the note that joined them. @@ -1363,7 +1381,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # a claim about the model and not the code. An endpoint the engine already made an entry point is modelled, and # the library-call rule above decides it. unmod = [] - if not (remote_found or keyed or lib_side): + if not (remote_found or keyed or lib_side or opaque): called = {y for ys in adj.values() for y in ys} eps = set() ends = [i for i in list(B_) + list(A_) if i in g.sym and g.sym[i]['kind'] not in ('library', 'written') and i not in called] @@ -1378,10 +1396,20 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): " does: NOT independent" if keyed else " — but a library called above can reach the other at run time (an event, a callback, a hook) with no" " call edge to record it: NOT shown to be independent" if lib_side + else f" — but {' and '.join(lx for lx, _ in opaque)} make{'s' if len(opaque) == 1 else ''} a call whose callee is a" + f" value, not a name (below): any function passed, stored or exported may be what it calls, so the" + f" connection is UNKNOWN, not absent" if opaque else f" — but NOT CHECKED: {'; '.join(s_ for _i, s_ in unmod[:3])} — a framework calls it and the graph does not" f" model how, so the other may reach it through that framework. Before treating them as independent run: " f"{grep_for(g, g.sym[unmod[0][0]]['name'])}" if unmod else ": the two are independent in this graph")) + WHY = {'parameter': 'a parameter', 'loop_variable': 'a loop variable', 'local': 'a local', 'computed': 'a computed member', + 'dynamic_code': 'code built at run time'} + for lx, rows in opaque: + print(f" {lx} reaches {len(rows)} call(s) through a value:") + for r in rows[:4]: + print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}") + if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) return 1 diff --git a/tests/cases/javascript/value-callee-is-unknown/case.json b/tests/cases/javascript/value-callee-is-unknown/case.json new file mode 100644 index 000000000..723d51064 --- /dev/null +++ b/tests/cases/javascript/value-callee-is-unknown/case.json @@ -0,0 +1,170 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "invoke calls a parameter: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "invoke", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`cb()` in invoke at src/handlers.js:6 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "dispatch calls a computed member (table[k]()): the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "dispatch", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/handlers.js:5 — the callee is a computed member", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "runList calls a for-of loop variable: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "runList", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`f()` in runList at src/handlers.js:7 — the callee is a loop variable", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "runAll calls an arrow's parameter inside forEach: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "runAll", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`h()` in at src/handlers.js:4 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "viaLocal calls a local with no value: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "viaLocal", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`fn()` in viaLocal at src/handlers.js:8 — the callee is a local", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "viaCall calls a parameter through .call: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "viaCall", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/handlers.js:9 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the reverse question is the same connection: the value call is on the other side (#1649)", + "run": [ + "path", + "alpha", + "invoke" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`cb()` in invoke" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: a function that calls nothing stays independent", + "run": [ + "path", + "pure", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a call through a required module binding names a module member, not a value", + "run": [ + "path", + "joins", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a resolved call is still a chain", + "run": [ + "path", + "direct", + "alpha" + ], + "want": [ + "direct → alpha: 1 of 1 target(s) reached" + ], + "avoid": [ + "connection is UNKNOWN, not absent" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/value-callee-is-unknown/src/handlers.js b/tests/cases/javascript/value-callee-is-unknown/src/handlers.js new file mode 100644 index 000000000..9fa522427 --- /dev/null +++ b/tests/cases/javascript/value-callee-is-unknown/src/handlers.js @@ -0,0 +1,13 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +function runAll(handlers) { handlers.forEach(h => h()); } +function dispatch(table, k) { return table[k](); } +function invoke(cb) { return cb(); } +function runList(ctx) { for (const f of ctx.list) f(); } +function viaLocal(o) { const fn = o.pick; return fn(); } +function viaCall(cb) { return cb.call(null); } +function pure() { return 1 + 2; } +function joins() { return path.join('a', 'b'); } +function direct() { return alpha(); } +module.exports = { alpha, runAll, dispatch, invoke, runList, viaLocal, viaCall, pure, joins, direct }; From f930fce2874d28f7e74a5ec541710c88be8fd473 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:48:15 -0700 Subject: [PATCH 15/37] tests: 64-memoize-wrapper-result expects the value_callee diagnostic for its parameter call Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/test/javascript/expected/64-memoize-wrapper-result.diag | 1 + 1 file changed, 1 insertion(+) diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.diag b/graph/test/javascript/expected/64-memoize-wrapper-result.diag index 73bd4eb57..f9fd9b4b2 100644 --- a/graph/test/javascript/expected/64-memoize-wrapper-result.diag +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.diag @@ -1,3 +1,4 @@ package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - unresolved memoize.js:7:14 FUNCTION_CALL fn callee_untyped unresolved memoize.js:7:14 FUNCTION_CALL fn param_fan_capped +value_callee memoize.js:7:14 fn parameter From cca0e782b0c012b4335f2e90cd44180e482447dd Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:38:09 -0700 Subject: [PATCH 16/37] freshness: a graph built by another engine, other rules or another IMPACT_VERSION is stale The file table now records what built the graph (the engine's version and content hash, the plugin's rules hash, IMPACT_VERSION). `index`, the queries and the refresher compare it with the axiomcode that would build now: a difference rebuilds, with no file changed, and says "graph built by an older axiomcode ( -> )". A query answers from the old graph at once with that line while the refresher rebuilds; --fresh waits for it. The same engine, even reinstalled in another directory, with no edit, is still up to date. --- .../skills/axiomcode/scripts/ax_fresh.py | 209 ++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-build | 9 +- tests/freshness.py | 75 ++++++- tests/refresh.py | 28 +++ 4 files changed, 299 insertions(+), 22 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 9790916f4..31266f058 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -17,7 +17,7 @@ every row that lies in a file changed since marked (`query`); they wait only when the answer touches such a file and the refresh is expected within a small budget, or when asked (--fresh). - ax_fresh.py snapshot print the file table (JSON) — axiomcode-build stores it + ax_fresh.py snapshot print the file table (JSON), with what built the graph — axiomcode-build stores it ax_fresh.py status [--json] fresh | stale (+ the changed files) | building | no graph ax_fresh.py kick start the worker if the graph is stale; never waits ax_fresh.py wait [] kick, then wait up to for a fresh graph @@ -241,6 +241,152 @@ def changes(repo, table=None): if p.endswith(SOURCE[l]) and rel not in old: added.append(rel); break return sorted(changed), sorted(added), sorted(set(old) - seen) +# ── WHAT BUILT THE GRAPH ─────────────────────────────────────────────────────────────────────────────────────────── +# A graph is current when the files it was built from are unchanged AND the axiomcode that built it is the one that +# would build it now. Only the first was checked: after a plugin or engine update, `index` said "graph up to date" and +# every query answered from a graph the previous engine had solved, with the previous rules, until someone deleted +# .axiomcode by hand. So the file table also records what built it (`built_by`), and a difference is a stale graph: +# engine a hash of what the engine builds a graph with (its parser, rules, pipeline, launcher), and its version +# rules a hash of this plugin's code that shapes the graph and its answers: the query rules (dl/*.dl) and +# axiomcode-index, which writes the symbols every verb reads +# impact IMPACT_VERSION, the version of the facts `impact` exports from the graph +# The engine is found the way axiomcode-build finds it (AXIOMCODE_ENGINE, the checkout this plugin sits in, the engine +# that built this graph, an `axiomcode` on PATH); when none of those is a built engine it is not compared, since +# `npm root -g` is too slow to ask before every query. Only content counts: an install moved to another directory +# with the same bytes is the same engine. +ENGINE_TREES = ('bin', 'dist', 'graph', os.path.join('parser', 'dist')) +ENGINE_FILES = ('package.json', os.path.join('parser', 'package.json')) +ENGINE_SKIP_DIRS = frozenset({'test', 'node_modules', '__pycache__', '.cache'}) +ENGINE_SKIP_EXT = ('.map', '.md', '.d.ts', '.tsbuildinfo', '.pyc') + +def engine_ok(d): + return bool(d) and os.path.isfile(os.path.join(d, 'bin', 'axiomcode')) and os.path.isdir(os.path.join(d, 'graph')) \ + and os.path.isfile(os.path.join(d, 'package.json')) + +def engine_built(d): + return engine_ok(d) and os.path.isfile(os.path.join(d, 'parser', 'dist', 'index.js')) + +def current_engine(repo): + """the engine the next build of repo would use (axiomcode-build's order, without its last resort, npm root -g), or + None when none of those is an engine""" + e = os.environ.get('AXIOMCODE_ENGINE') + if e and engine_ok(e): return e + w = H + while os.path.dirname(w) != w and not os.path.isfile(os.path.join(w, 'bin', 'axiomcode')): w = os.path.dirname(w) + if engine_built(w): return w + try: rec = open(os.path.join(repo, '.axiomcode', 'engine')).read().strip() + except OSError: rec = '' + if engine_built(rec): return rec + import shutil + p = shutil.which('axiomcode') + if p: + d = os.path.dirname(os.path.dirname(os.path.realpath(p))) + if engine_built(d): return d + d = os.path.join(os.path.dirname(p), 'node_modules', '@axiomcode', 'code-graph') + if engine_built(d): return d + return None + +def _engine_files(d): + for t in ENGINE_TREES: + for root, subdirs, files in os.walk(os.path.join(d, t)): + subdirs[:] = sorted(s for s in subdirs if s not in ENGINE_SKIP_DIRS and not s.startswith('.')) + for f in sorted(files): + if not f.endswith(ENGINE_SKIP_EXT): yield os.path.join(root, f) + for f in ENGINE_FILES: + if os.path.isfile(os.path.join(d, f)): yield os.path.join(d, f) + +_ENGINE_MEMO = {} + +def engine_id(d): + """(version, content hash, stat signature) of the engine at d. The stat signature (every file's size and mtime) is + what is compared first; the content hash, which reads every file, only when the signature differs, and it is kept + for that signature for the life of the process""" + stats = [] + for p in _engine_files(d): + try: st = os.stat(p); stats.append((os.path.relpath(p, d), st.st_size, st.st_mtime_ns)) + except OSError: pass + sig = hashlib.sha1(json.dumps(stats).encode()).hexdigest() + try: version = json.load(open(os.path.join(d, 'package.json'))).get('version', '?') + except (OSError, ValueError): version = '?' + return version, (lambda: _engine_hash(d, sig, stats)), sig + +def _engine_hash(d, sig, stats): + k = (os.path.realpath(d), sig) + if k not in _ENGINE_MEMO: + h = hashlib.sha1() + for rel, _, _ in stats: + h.update(rel.replace(os.sep, '/').encode() + b'\0') + try: h.update(digest(os.path.join(d, rel)).encode()) + except OSError: pass + _ENGINE_MEMO[k] = h.hexdigest() + return _ENGINE_MEMO[k] + +IMPACT_VERSION_RE = re.compile(r"^\s*IMPACT_VERSION\s*=\s*'([^']*)'", re.M) + +def plugin_id(): + """(rules hash, IMPACT_VERSION) of the plugin this script belongs to""" + h = hashlib.sha1() + dl = os.path.join(H, 'dl') + try: names = sorted(f for f in os.listdir(dl) if f.endswith('.dl')) + except OSError: names = [] + for f in names + ['axiomcode-index']: + p = os.path.join(dl, f) if f.endswith('.dl') else os.path.join(H, f) + try: h.update(f.encode() + b'\0' + digest(p).encode()) + except OSError: pass + try: m = IMPACT_VERSION_RE.search(open(os.path.join(H, 'axiomcode-impact'), errors='replace').read()) + except OSError: m = None + return h.hexdigest(), (m.group(1) if m else '?') + +def built_by(engine): + """what a build with `engine` records in its file table""" + rules, impact = plugin_id() + d = dict(rules=rules, impact=impact) + if engine and engine_ok(engine): + version, content, sig = engine_id(engine) + d.update(engine=engine, engine_version=version, engine_hash=content(), engine_stat=sig) + return d + +def _label(version, h): return f"{version} {h[:8]}" if h else version + +def engine_change(repo, t=None): + """'' when the graph was built by the axiomcode that would build it now, else what differs, written + "graph built by an older axiomcode ( -> )". A table from before this was recorded differs. A graph + placed by AXIOMCODE_GRAPH is not this repository's build, and AXIOMCODE_NO_ENGINE_CHECK=1 turns the check off""" + if os.environ.get('AXIOMCODE_GRAPH') or os.environ.get('AXIOMCODE_NO_ENGINE_CHECK'): return '' + t = t if t is not None else load_table(repo) + if not t: return '' + old = t.get('built_by') + rules, impact = plugin_id() + eng = current_engine(repo) + now_e = engine_id(eng) if eng else None + if not isinstance(old, dict): + new = f"{now_e[0]} {now_e[1]()[:8]}" if now_e else f"IMPACT_VERSION {impact}" + return f"graph built by an older axiomcode (one that did not record its engine -> {new})" + diff = [] + if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: + h = _seen_hash(repo, now_e) + if h != old['engine_hash']: + diff.append(f"engine {_label(old.get('engine_version', '?'), old['engine_hash'])} -> {_label(now_e[0], h)}") + elif now_e and not old.get('engine_hash'): + diff.append(f"engine unrecorded -> {_label(now_e[0], _seen_hash(repo, now_e))}") + if old.get('rules') != rules: diff.append(f"rules {(old.get('rules') or 'unrecorded')[:8]} -> {rules[:8]}") + if str(old.get('impact')) != impact: diff.append(f"IMPACT_VERSION {old.get('impact') or 'unrecorded'} -> {impact}") + return f"graph built by an older axiomcode ({'; '.join(diff)})" if diff else '' + +def _seen_hash(repo, e): + """the content hash of engine e: an engine whose files moved (a reinstall, a new checkout) but whose bytes did not + is hashed once, and its hash kept beside the graph for its stat signature, so later queries only stat it""" + p = os.path.join(out_dir(repo), 'engine-seen.json') + try: seen = json.load(open(p)) + except (OSError, ValueError): seen = {} + if e[2] in seen: return seen[e[2]] + h = e[1]() + seen = {e[2]: h} # the latest one only: this is a cache, not a history + tmp = p + f'.{os.getpid()}' + try: json.dump(seen, open(tmp, 'w')); os.replace(tmp, p) + except OSError: pass + return h + def read_state(repo): try: return json.load(open(state_path(repo))) except (OSError, ValueError): return {} @@ -395,18 +541,26 @@ def status(repo): if c is None: return dict(state='unknown', note='the graph predates the file table; the next `axiomcode index` records it') changed, added, removed = c busy = building(repo) - if not (changed or added or removed): + eng = engine_change(repo) + if not (changed or added or removed) and not eng: # a build that already published this tree's main graph and is still solving other languages: the graph a query # reads is current, and waiting would be waiting for other languages' compiles if busy: return dict(state='building', **pending(repo)) return dict(state='stale', changed=[unbuilt_row(repo)], added=[], removed=[]) if unbuilt(repo) else dict(state='fresh') d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed) - if not busy and st.get('failed_table') == change_key(c): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') + if eng: d['engine'] = eng + if busy and not (changed or added or removed): d.update(pending(repo)) + if not busy and st.get('failed_table') == change_key(c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') return d -def change_key(c): - """identifies a change set, so a build that failed on it is not retried until the files move again""" - return hashlib.sha1(json.dumps(c).encode()).hexdigest() +def change_key(c, engine=''): + """identifies a change set (and what built the graph, when that differs), so a build that failed on it is not + retried until the files move again or another axiomcode is installed""" + return hashlib.sha1(json.dumps([c, engine] if engine else c).encode()).hexdigest() + +def behind(s): + """the graph is behind: files edited since it was built, or built by another axiomcode""" + return bool(edited(s) or s.get('engine')) def enabled(repo): return not os.environ.get('AXIOMCODE_NO_REFRESH') and not os.environ.get('AXIOMCODE_GRAPH') and has_graph(repo) @@ -446,7 +600,7 @@ def worker(repo): lock = os.path.join(repo, '.axiomcode', 'refresh.lock') fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644) if not _flock(fd, False): return 0 # single flight - debounce = float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2); legacy_done = False + debounce = float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2); legacy_done = False; engine_done = ''; eng = '' try: for _ in range(8): # bounded: a tree rewritten faster than it builds must not spin forever time.sleep(debounce) @@ -462,19 +616,26 @@ def worker(repo): c = changes(repo, t) if graph_broken(repo) and not (c and any(c)): c = [['.axiomcode/out/graph.sqlite (broken pointer)'], [], []] elif unbuilt(repo) and not (c and any(c)): c = [[unbuilt_row(repo)], [], []] - if (c is None or not any(c)) and not base_moved(repo): + # BUILT BY ANOTHER AXIOMCODE: rebuilt with no file changed, once per worker. A rebuild that leaves the same + # difference behind (a build that could not record what built it) is not repeated + eng = engine_change(repo, t) + if eng and eng == engine_done: eng = '' + if (c is None or not any(c)) and not eng and not base_moved(repo): write_state(repo, state='fresh', checked=time.time(), checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')); return 0 c = c or [[], [], []] st = read_state(repo) # a build that FAILED on exactly this tree is not retried on every trigger: the next edit retries it - fp = change_key(c) + fp = change_key(c, eng) if st.get('failed_table') == fp: return 0 n = sum(len(x) for x in c) - why = (f"{n} file(s) changed" if n else f"HEAD moved to {(head(repo) or '')[:10]}") + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" + why = (f"{n} file(s) changed" if n else eng if eng else f"HEAD moved to {(head(repo) or '')[:10]}") + \ + (f"; {eng}" if n and eng else '') + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" + engine_done = eng env = rebuild_env(t, AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c)) if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True) - else: print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) + if eng: print(f"{time.strftime('%H:%M:%S')} refresh: {eng}; rebuilding", flush=True) + elif not any(c): print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) # the worker is detached and has no console, so Windows would give the console program bash a new, visible # window for the length of every rebuild; CREATE_NO_WINDOW keeps it hidden r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -572,6 +733,13 @@ def note(s, marked=None, named=None, off=False): # the languages a running build has still to publish (#1555) get a line of their own, before any line about edits first = pending_note(s) if s.get('state') not in ('stale', 'building'): return first + if s.get('engine'): + # BUILT BY ANOTHER AXIOMCODE: every row may differ from what this version answers, so none is marked; the line says so + if off: says = "refresh is OFF (AXIOMCODE_NO_REFRESH is set), so nothing rebuilds it; `axiomcode index` does" + elif s.get('failed'): says = "the rebuild FAILED" + (f" — {s['failed_reason']}" if s.get('failed_reason') else '') + f" (see {s['failed']})" + else: says = "rebuilding in the background; --fresh waits for the rebuild" + first = (first + '\n' if first else '') + f"graph refresh: {s['engine']}; {says} — this answer is from that graph, " \ + "and may lack what the installed version finds" files = edited(s) if not files: return first first = first + '\n' if first else '' @@ -748,7 +916,7 @@ def wait_fresh(repo, seconds, say=False, files=()): end = t0 = time.time(); end += seconds; last = t0 while True: s = status(repo) - if (s['state'] in ('fresh', 'no graph', 'unknown') or not edited(s)) and not _waits_on(s, files): return True + if (s['state'] in ('fresh', 'no graph', 'unknown') or not behind(s)) and not _waits_on(s, files): return True if s.get('failed'): return False if s['state'] == 'stale': kick(repo, 'a query') now = time.time() @@ -783,7 +951,7 @@ def with_note(n): # the answer as it is, then if s['state'] == 'unknown': if not off: kick(repo, 'a query') passthrough() - if s['state'] in ('fresh', 'no graph') or not edited(s): + if s['state'] in ('fresh', 'no graph') or not behind(s): # a build that published this tree's main graph and is solving the others (#1555): the answer is current and is # given now, and says which languages it cannot see yet if not pending_note(s): passthrough() @@ -792,7 +960,7 @@ def with_note(n): # the answer as it is, then as_json = '--json' in argv if fresh and not s.get('failed') and not off: left = expected_left(repo) - print(f"waiting for the graph to refresh (--fresh): {len(edited(s))} file(s) changed since the graph was built" + + print(f"waiting for the graph to refresh (--fresh): " + (f"{len(edited(s))} file(s) changed since the graph was built" if edited(s) else s.get('engine', '')) + (f", the last build took {int(build_seconds(repo)[0])} s" if left is not None else '') + " …", file=sys.stderr, flush=True) if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True, files=True): passthrough() s = status(repo) @@ -822,6 +990,7 @@ def with_note(n): # the answer as it is, then obj = json.loads(marked) if isinstance(obj, dict): obj['freshness'] = dict(state='off' if off else s['state'], edited=edited(s), rows_marked=n, + **({'built_by': s['engine']} if s.get('engine') else {}), **({'failed': s['failed']} if s.get('failed') else {}), **({'named_in_edits': [dict(name=a, file=b) for a, b in missed]} if missed else {})) marked = json.dumps(obj, indent=1, ensure_ascii=False) + '\n' @@ -840,8 +1009,11 @@ def main(argv): if cmd == 'snapshot': lang, src_arg = argv[3], (argv[4] if len(argv) > 4 else '') lib = os.environ.get('AXIOMCODE_LIBRARY', '') + # what built the graph (the engine axiomcode-build found, passed as AXIOMCODE_ENGINE), unless this table is taken + # for a graph an earlier build made (AXIOMCODE_BUILT_BY_UNKNOWN), which must not be credited to this one + by = {} if os.environ.get('AXIOMCODE_BUILT_BY_UNKNOWN') else dict(built_by=built_by(os.environ.get('AXIOMCODE_ENGINE') or current_engine(repo))) json.dump(dict(lang=lang, lang_auto=bool(os.environ.get('AXIOMCODE_LANG_AUTO')), src=src_arg.strip('/'), src_arg=src_arg, library=lib, built=time.time(), - files=snapshot(repo, lang, os.path.join(repo, src_arg))), sys.stdout); return 0 + files=snapshot(repo, lang, os.path.join(repo, src_arg)), **by), sys.stdout); return 0 if cmd == 'count': # the SOURCE files of each language under repo, walked as the refresher walks (the parser's skip list and git's # ignore rules): java typescript python javascript csharp. axiomcode-build picks the main language from these, and @@ -871,7 +1043,12 @@ def main(argv): t = load_table(repo) if not t or t.get('lang') != lang or t.get('src_arg', '') != src_arg or (t.get('library') or 'nolib') != (lib or 'nolib'): return 1 c = changes(repo, t) - return 0 if c is not None and not any(c) else 1 + if c is None or any(c): return 1 + # the files are the graph's; the axiomcode that built it must be the one building now (axiomcode-build passes + # the engine it found as AXIOMCODE_ENGINE), or the graph is rebuilt, saying why + eng = engine_change(repo, t) + if eng: print(f"{eng}; rebuilding", flush=True); return 1 + return 0 if cmd == 'status': s = status(repo) if '--json' in argv: print(json.dumps(dict(s, **refreshed(repo)))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 9d15fe1c7..6ac2afbc3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -242,7 +242,7 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true @@ -254,7 +254,7 @@ if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. python3 "$H/dl_program.py" --no-wait || true # say whether the query rules are compiled (started at the top); never wait # a graph from before the file table: it was built from this clean commit, so the table of the files now IS its table - python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" + AXIOMCODE_BUILT_BY_UNKNOWN=1 python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0; fi if [ -z "$OTHER_LANGS" ]; then echo "building $LANG_ARG graph for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)" else echo "building ${LANGS//,/ + } graphs for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)"; fi @@ -265,8 +265,9 @@ else echo "building ${LANGS//,/ + } graphs for $SRC … (java $ja · typescript # throwaway index: the user's index and checkout are not touched, and .axiomcode is excluded above. Kept only if the # build succeeds. # THE FILE TABLE, taken now for the same reason: an edit made while the parser runs must leave the graph stale, so the -# next refresh picks it up. Committed only if the build succeeds. -python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" +# next refresh picks it up. Committed only if the build succeeds. It records what built the graph, the engine above +# and this plugin's rules, so a graph an older axiomcode built is not "up to date" after an update. +AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" INDEXED_TREE="" if [ -n "$GD" ]; then TMPIDX="$(mktemp "${TMPDIR:-/tmp}/axiomcode-index.XXXXXX")" diff --git a/tests/freshness.py b/tests/freshness.py index 09ef0b699..ba6c32189 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -18,6 +18,9 @@ answered with no mark and no note named "nothing named X" for an X an edit newer than the graph wrote says so, names the file and whether a refresh runs, also with AXIOMCODE_NO_REFRESH; a name no edit writes, or one the answer found, is not blamed + engine a graph built by another engine, other rules or another IMPACT_VERSION is stale with no file changed: the + answer comes from it at once with a note, and `index` rebuilds it saying why; the same engine, even reinstalled + elsewhere, with no edit, is current mcp the MCP tools take fresh=true and pass --fresh, and the CLI's --fresh is written fresh=True in an answer No engine: the wait checks drive `ax_fresh.py query` with a stand-in verb, and a stand-in refresh that brings the file @@ -153,7 +156,7 @@ def fake_repo(work, name, build_seconds='3 0', log=''): write(repo, 'shop/report.py', 'def report(items):\n return total(items)\n') write(repo, '.axiomcode/out/graph.sqlite', '') table = dict(lang='python', lang_auto=False, src='', src_arg='', library='', built=time.time(), - files=ax_fresh.snapshot(repo, 'python', repo)) + files=ax_fresh.snapshot(repo, 'python', repo), built_by=ax_fresh.built_by(ax_fresh.current_engine(repo))) json.dump(table, open(os.path.join(repo, '.axiomcode/out/files.json'), 'w')) if build_seconds: write(repo, '.axiomcode/out/build-seconds', build_seconds + '\n') json.dump(dict(state='building', started=time.time()), open(os.path.join(repo, '.axiomcode/refresh.json'), 'w')) @@ -225,6 +228,74 @@ def wait_checks(): shutil.rmtree(work, ignore_errors=True) +# ── engine ──────────────────────────────────────────────────────────────────────────────────────────────────────── +def fake_engine(d, rules='rel(1).\n', version='1.0.0'): + for rel, text in (('bin/axiomcode', '#!/bin/sh\n'), ('graph/python/rules.dl', rules), ('package.json', json.dumps(dict(version=version))), + ('parser/dist/index.js', '// parser\n'), ('parser/dist/index.js.map', '{}'), ('graph/test/case.dl', 'x.\n')): + write(d, rel, text) + return d + + +def engine_checks(): + """a graph built by another engine, other rules or another IMPACT_VERSION is stale with no file changed, and says so; + the same engine, moved or not, with no edit, is current (the near-miss)""" + work = tempfile.mkdtemp(prefix='axiomcode-engine-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e1 = fake_engine(os.path.join(work, 'e1')) + os.environ['AXIOMCODE_ENGINE'] = e1 + repo = fake_repo(work, 'repo'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + tp = os.path.join(repo, '.axiomcode/out/files.json'); table = json.load(open(tp)) + uptodate = lambda: subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'uptodate', repo, 'python', '', ''], + capture_output=True, text=True, env=dict(os.environ)) + by = table['built_by'] + check("engine: the file table records the engine (its version and content hash), the rules and IMPACT_VERSION", + by.get('engine_version') == '1.0.0' and len(by.get('engine_hash', '')) == 40 and len(by.get('rules', '')) == 40 + and by.get('impact') == ax_fresh.plugin_id()[1] and by['impact'] not in ('', '?'), by) + u = uptodate() + check("engine: control: the same engine and no edit is fresh, and `index` finds it up to date", + ax_fresh.status(repo).get('state') == 'fresh' and u.returncode == 0 and not u.stdout.strip(), (ax_fresh.status(repo), u.stdout)) + # the same bytes in another directory, every mtime new: a reinstall that changed nothing + e2 = os.path.join(work, 'e2'); time.sleep(0.01); fake_engine(e2); os.environ['AXIOMCODE_ENGINE'] = e2 + check("engine: control: the same engine reinstalled elsewhere (new paths and mtimes, same bytes) is fresh", + ax_fresh.engine_change(repo) == '' and ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.engine_change(repo)) + check("engine: its test and source-map files are not part of the engine", + not any(p.endswith(('.map', os.path.join('test', 'case.dl'))) for p in ax_fresh._engine_files(e2)), list(ax_fresh._engine_files(e2))) + fake_engine(e2, rules='rel(2).\n', version='1.0.1') + s = ax_fresh.status(repo); n = ax_fresh.note(s); u = uptodate() + old, new = by['engine_hash'][:8], ax_fresh.engine_id(e2)[1]()[:8] + check("engine: another engine makes the graph stale with no file changed, and names both", + s.get('state') == 'stale' and not ax_fresh.edited(s) and s.get('engine') == f"graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new})", s) + check("engine: the answer's note says it comes from that graph while it is rebuilt", + n.startswith(f"graph refresh: graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new}); rebuilding in the background"), n) + check("engine: `index` rebuilds it and says why", u.returncode == 1 and u.stdout.strip() == f"graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new}); rebuilding", u.stdout) + # the query: an answer at once from the graph it has, unmarked, with the note; the refresh is kicked, not waited for + driver = os.path.join(work, 'driver.py'); open(driver, 'w').write(DRIVER) + t0 = time.time() + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, 'impact', '--', sys.executable, '-c', f"print({ROWS!r})"], + capture_output=True, text=True, env={k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_FRESH')}) + check(f"engine: a query answers from the old graph at once ({time.time() - t0:.1f}s), rows unmarked, and says it is rebuilding", + r.stdout.strip() == ROWS.strip() and 'graph built by an older axiomcode (engine' in r.stderr and 'rebuilding in the background' in r.stderr + and time.time() - t0 < 10, (r.stdout, r.stderr)) + os.environ['AXIOMCODE_ENGINE'] = e1 + t = dict(table, built_by=dict(by, impact='1')); json.dump(t, open(tp, 'w')) + check("engine: another IMPACT_VERSION makes it stale, named as such", + ax_fresh.engine_change(repo) == f"graph built by an older axiomcode (IMPACT_VERSION 1 -> {by['impact']})", ax_fresh.engine_change(repo)) + t = dict(table, built_by=dict(by, rules='f' * 40)); json.dump(t, open(tp, 'w')) + check("engine: other query rules make it stale", ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (rules ffffffff -> '), ax_fresh.engine_change(repo)) + t = dict(table); t.pop('built_by'); json.dump(t, open(tp, 'w')) + check("engine: a table that does not say what built it (every graph from before this) is stale", + ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (one that did not record its engine -> 1.0.0 '), ax_fresh.engine_change(repo)) + json.dump(table, open(tp, 'w')) + check("engine: control: put back, it is fresh again", ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.status(repo)) + e = dict(os.environ, AXIOMCODE_ENGINE=e2, AXIOMCODE_NO_ENGINE_CHECK='1'); os.environ.update(e) + check("engine: AXIOMCODE_NO_ENGINE_CHECK=1 turns the check off", ax_fresh.engine_change(repo) == '') + finally: + os.environ.pop('AXIOMCODE_NO_ENGINE_CHECK', None) + if saved is None: os.environ.pop('AXIOMCODE_ENGINE', None) + else: os.environ['AXIOMCODE_ENGINE'] = saved + shutil.rmtree(work, ignore_errors=True) + + # ── named ───────────────────────────────────────────────────────────────────────────────────────────────────────── NOT_FOUND = "nothing named 'audit' in the graph, and nothing close to it." @@ -296,7 +367,7 @@ def mcp_checks(): if __name__ == '__main__': - prune_checks(); marks_checks(); wait_checks(); named_checks(); mcp_checks() + prune_checks(); marks_checks(); wait_checks(); engine_checks(); named_checks(); mcp_checks() bad = [n for n, ok in RESULTS if not ok] print(f"\n{len(RESULTS) - len(bad)} of {len(RESULTS)} passed" + (f"; FAILED: {len(bad)}" if bad else '')) sys.exit(1 if bad or not RESULTS else 0) diff --git a/tests/refresh.py b/tests/refresh.py index 787aebff7..39d7c3bfd 100644 --- a/tests/refresh.py +++ b/tests/refresh.py @@ -109,6 +109,34 @@ def check(ok, why, detail=''): t0 = time.time(); again = sh(repo, AX, 'index', '.', '--lang', lang, env=env); took = time.time() - t0 check('graph up to date' in again.stdout, f'{lang}: `index` with nothing changed does not rebuild ({took:.1f}s)', again.stdout + again.stderr) + # ── built by an older axiomcode ──────────────────────────────────────────────────────────────────── + # no file changed, but the table says another engine and another IMPACT_VERSION built the graph (what a plugin + # update leaves behind): not up to date. A query answers from it at once and says so, the refresher rebuilds + # it, and `index` rebuilds it saying why. The control is the check above: same engine, no edit, "graph up to + # date" and no rebuild + tp = os.path.join(out, 'files.json') + def older(): + t = json.load(open(tp)); by = t.get('built_by') or {} + check(by.get('engine_hash') and by.get('rules') and by.get('impact'), f'{lang}: the file table records the engine, rules and IMPACT_VERSION that built the graph', json.dumps(by)) + by.update(engine_hash='0' * 40, engine_stat='0' * 40, engine_version='0.0.1', impact='1'); t['built_by'] = by + json.dump(t, open(tp, 'w')) + older() + q = sh(repo, AX, 'impact', helper, '.', env=quiet) + check(q.returncode == 0 and helper in q.stdout and 'graph built by an older axiomcode (engine 0.0.1 00000000 ->' in q.stderr + and 'IMPACT_VERSION 1 ->' in q.stderr and 'nothing rebuilds it' in q.stderr, + f'{lang}: a query over a graph an older axiomcode built answers from it and says so', q.stdout[-300:] + q.stderr) + fr = sh(repo, AX, 'impact', helper, '.', '--fresh', env=env) + m = meta() + check(fr.returncode == 0 and 'graph built by an older axiomcode' in fr.stderr and 'graph refresh:' not in fr.stderr + and 'graph built by an older axiomcode' in m.get('refresh_reason', '') and 'found by a query' in m.get('refresh_reason', ''), + f'{lang}: the refresher rebuilds it with no file changed, and --fresh answers from the new graph', fr.stderr + json.dumps(m)) + older() + ix = sh(repo, AX, 'index', '.', '--lang', lang, env=env) + check(ix.returncode == 0 and 'graph built by an older axiomcode' in ix.stdout and '; rebuilding' in ix.stdout and 'graph up to date' not in ix.stdout, + f'{lang}: `index` over it rebuilds, and says why', ix.stdout + ix.stderr) + again = sh(repo, AX, 'index', '.', '--lang', lang, env=env) + check('graph up to date' in again.stdout and 'older axiomcode' not in again.stdout, f'{lang}: control: then `index` finds it up to date', again.stdout + again.stderr) + # ── an added function ───────────────────────────────────────────────────────────────────────────── f = os.path.join(repo, L['edit']); text = open(f).read() if isinstance(L['add'], tuple): text = text[:text.rindex(L['add'][0])] + L['add'][1] From 623fd1cba7e23b3ee211fcafbfd8b6e4122584b0 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:49:05 -0700 Subject: [PATCH 17/37] freshness: a failed rebuild is still reported as failed to a caller whose engine matches the graph The failed change set is keyed on the files only; the engine difference it failed on is kept beside it and compared only when the caller sees one. --- .../skills/axiomcode/scripts/ax_fresh.py | 22 ++++++++++++------- tests/freshness.py | 4 ++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 31266f058..2973e71f5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -550,13 +550,19 @@ def status(repo): d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed) if eng: d['engine'] = eng if busy and not (changed or added or removed): d.update(pending(repo)) - if not busy and st.get('failed_table') == change_key(c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') + if not busy and failed_on(st, c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') return d -def change_key(c, engine=''): - """identifies a change set (and what built the graph, when that differs), so a build that failed on it is not - retried until the files move again or another axiomcode is installed""" - return hashlib.sha1(json.dumps([c, engine] if engine else c).encode()).hexdigest() +def change_key(c): + """identifies a change set, so a build that failed on it is not retried until the files move again""" + return hashlib.sha1(json.dumps(c).encode()).hexdigest() + +def failed_on(st, c, engine=''): + """the last rebuild failed on exactly this change set and, when the graph is behind the installed axiomcode, on + the same difference: it is not retried until the files move or another axiomcode is installed. The difference is + compared only when there is one: a failed rebuild seen by a caller whose engine matches the graph (a shell without + the AXIOMCODE_ENGINE the refresher ran with) is still the failed rebuild of these files""" + return st.get('failed_table') == change_key(c) and (not engine or st.get('failed_engine', '') == engine) def behind(s): """the graph is behind: files edited since it was built, or built by another axiomcode""" @@ -625,8 +631,8 @@ def worker(repo): c = c or [[], [], []] st = read_state(repo) # a build that FAILED on exactly this tree is not retried on every trigger: the next edit retries it - fp = change_key(c, eng) - if st.get('failed_table') == fp: return 0 + fp = change_key(c) + if failed_on(st, c, eng): return 0 n = sum(len(x) for x in c) why = (f"{n} file(s) changed" if n else eng if eng else f"HEAD moved to {(head(repo) or '')[:10]}") + \ (f"; {eng}" if n and eng else '') + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" @@ -642,7 +648,7 @@ def worker(repo): **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) took = round(time.time() - t0, 1) if r.returncode != 0: - write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, + write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, failed_engine=eng, failed_log=os.path.join(repo, '.axiomcode', 'refresh.log'), failed_reason=failure_reason(r.stdout)) print(f"refresh: build failed after {took}s; the previous graph is kept\n{r.stdout[-800:]}", flush=True) return 1 diff --git a/tests/freshness.py b/tests/freshness.py index ba6c32189..c792a95ee 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -287,6 +287,10 @@ def engine_checks(): ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (one that did not record its engine -> 1.0.0 '), ax_fresh.engine_change(repo)) json.dump(table, open(tp, 'w')) check("engine: control: put back, it is fresh again", ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.status(repo)) + c = [[], [], []]; st = dict(failed_table=ax_fresh.change_key(c), failed_engine='graph built by an older axiomcode (engine a -> b)') + check("engine: a rebuild that failed on a difference is not retried for it, but is for another one, or for another edit", + ax_fresh.failed_on(st, c, st['failed_engine']) and not ax_fresh.failed_on(st, c, 'graph built by an older axiomcode (engine a -> c)') + and not ax_fresh.failed_on(st, [['x.py'], [], []], st['failed_engine'])) e = dict(os.environ, AXIOMCODE_ENGINE=e2, AXIOMCODE_NO_ENGINE_CHECK='1'); os.environ.update(e) check("engine: AXIOMCODE_NO_ENGINE_CHECK=1 turns the check off", ax_fresh.engine_change(repo) == '') finally: From 8801820fe8bc955e633dbdc617e1d740df4a306b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:52:44 -0700 Subject: [PATCH 18/37] impact: a test that only stubs the change is not a test row to run The tests line now also names callables in test code that the count does not credit. A test that only stubs the change on a mock, or reaches it only through a type it holds as a mock, is listed apart as [stubs it] and is not one of them; without this it was told both "not counted" and "treat as a test to run". Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/axiomcode-impact | 5 ++++- .../cases/csharp/mock-stub-is-not-a-test-route/case.json | 9 ++++++++- tests/cases/java/mock-stub-is-not-a-test-route/case.json | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 1a3acbe37..b43006537 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2297,7 +2297,10 @@ def main(argv): # (a [test] entry point, a test row) but that the count does not credit -- a runner entry this tool does not # collect (a custom test attribute, a script's main), a remote hop the test walk does not take -- made "tests: 0" # sit under a [test] row. They are named here, and `next:` counts them as tests to run. - test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert != 'alongside'}) - set(tests), + # A test that only stubs it on a mock, or reaches it only through a type it holds as one, is not one of them: it is + # the [stubs it] line below, which says it is not counted because a body change cannot fail it. + test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert not in ('alongside', 'stubs it') + and not why.startswith('stubs a method of this name')}) - set(tests) - set(stubbed), key=lambda m: (g.disp(m), g.loc(m))) if test_rows: print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json index 127aef5bc..05ccdb04b 100644 --- a/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json @@ -20,7 +20,8 @@ "[fixture]", "MoqStubTests::", "[by name] 1 test", - "[by name] 2 test" + "[by name] 2 test", + "callable(s) in test code listed above reach it" ] }, { @@ -32,6 +33,9 @@ ], "want": [ "SnapshotTests::VerifiesASnapshot" + ], + "avoid": [ + "callable(s) in test code listed above reach it" ] }, { @@ -55,6 +59,9 @@ "want": [ "only stub it on a mock", "[resolved] Checkout.Total" + ], + "avoid": [ + "callable(s) in test code listed above reach it" ] } ] diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/case.json b/tests/cases/java/mock-stub-is-not-a-test-route/case.json index c5f7fecb2..a5678ad4f 100644 --- a/tests/cases/java/mock-stub-is-not-a-test-route/case.json +++ b/tests/cases/java/mock-stub-is-not-a-test-route/case.json @@ -22,7 +22,8 @@ "avoid": [ "[sound] 3", "[sound] 4", - "[fixture]" + "[fixture]", + "callable(s) in test code listed above reach it" ] }, { @@ -47,6 +48,9 @@ "want": [ "only stub it on a mock", "[resolved] Checkout.total" + ], + "avoid": [ + "callable(s) in test code listed above reach it" ] }, { From 9d2a9f050065a3de51723190ba0ba2e1ea2d12f9 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:38:55 -0700 Subject: [PATCH 19/37] csharp: a field, const, property or enum member is owned by its declaring type (#1445) The index mapped C# member declarations with no owner and a qualified_name of file#name, although every IR row links its declaring type by csTypeLinkHash. So impact Type.Field, Type.Prop and Type.CONST fell back to a type-name match and called the change local. The owner is now that type row: display Type.Member (Outer.Inner.Member when nested) and qualified_name .. A property then answers through its accessors, readers and writers resolved; a field or const lists readers in scope and by name, since no C# field access is resolved by the engine yet. New case with a same-named near-miss on another type. --- .../skills/axiomcode/reference/schema.md | 7 ++-- .../skills/axiomcode/scripts/axiomcode-index | 21 +++++++--- skills/axiomcode/reference/schema.md | 7 ++-- .../csharp/member-owner-is-its-type/case.json | 40 +++++++++++++++++++ .../src/App/App.csproj | 5 +++ .../src/App/AuditService.cs | 12 ++++++ .../src/App/Options.cs | 25 ++++++++++++ .../src/App/RetryService.cs | 10 +++++ 8 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 tests/cases/csharp/member-owner-is-its-type/case.json create mode 100644 tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj create mode 100644 tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs create mode 100644 tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs create mode 100644 tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md index 6fd14df6f..b423e757c 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/schema.md +++ b/plugins/axiomcode/skills/axiomcode/reference/schema.md @@ -26,8 +26,8 @@ tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. | base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | | base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | | entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | -| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`) | -| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field: **not recorded** (`field_access` empty; `refs` `NAME_REFERENCE` / `FIELD` by name) | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | | call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | | why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | | strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | @@ -46,7 +46,8 @@ in Python: its dispatch set is `dispatch_candidates` (basis `mro`). | methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | | subtypes of a type | `impact `: "must change with it" | | who writes a Java field | `impact .`: "produces or writes it" | -| who writes a C# property | `impact :`: answers through its accessors. `impact Type.Prop` and `Type._field` find nothing in C# on this build (the symbols carry no owner): fall back to the SQL below | +| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | +| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | | a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | | text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | | a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 5e7b034ca..55a16d40a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -148,13 +148,16 @@ A = { # still the decoration, and a wrong `args` mapping would be silently empty in the same way decorations=dict(file='all-csharp-attributes.csv', name='attributeName', owner='ownerHash', line='startLine', fileVia=('modules', 'csModuleLinkHash'), id='csAttributeUniqueHash'), - # fields carry no ownerQualifiedName (potentialQualifiedName is the field's TYPE), so the owner comes - # from the file the way javascript's does; a property is a member that reads as a field at its sites + # fields carry no ownerQualifiedName (potentialQualifiedName is the field's TYPE), but every member row links its + # declaring type by csTypeLinkHash: that type is the owner, so `Type.Field`, `Type.Prop` and `Type.CONST` name it + # and qualified_name is `.`, as Java's is (#1445). Without it the owner was empty and + # qualified_name `file#name`, so impact on Owner.Member fell back to a type-name match and called the change local. + # A property is a member that reads as a field at its sites decls=[dict(file='all-csharp-fields.csv', id='csFieldUniqueHash', kind=lambda r: 'const' if r.get('isConst') == 'true' else 'field', - name='name', owner=None, fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine'), + name='name', ownerVia='csTypeLinkHash', fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine'), dict(file='all-csharp-properties.csv', id='csPropertyUniqueHash', kind=lambda r: 'field', - name='name', owner=None, fileVia=('types', 'csTypeLinkHash'), line='startLine', end='endLine'), - dict(file='all-csharp-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner=None, fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine')], + name='name', ownerVia='csTypeLinkHash', fileVia=('types', 'csTypeLinkHash'), line='startLine', end='endLine'), + dict(file='all-csharp-enum-members.csv', kind=lambda r: 'enum_member', name='name', ownerVia='csTypeLinkHash', fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine')], boundNames=None, skipped='skipped-csharp-files.csv'), }[LANG] @@ -373,9 +376,15 @@ for d in A['decls']: name = r.get(d['name'], ''); if not name: continue fp = file_of(r, d); od = owner_display(r.get(d['owner'], '')) if d.get('owner') else None + # the owner as a link to the declaring type's row (C#): its display and qualified name come from that row, so a + # nested type's member is `Outer.Inner.Member` and two types' same-named members stay two symbols + ot = types.get(r.get(d['ownerVia'], '')) if d.get('ownerVia') else None + if ot: od = tdisplay(ot['id']) or None ln = int(r.get(d['line']) or 0); en = int(r.get(d['end']) or ln) if (fp, ln, name) in bound_at: continue # `const foo = () => …` is already the function foo - sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), (r.get('qualifiedName') or ((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name), None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) + qn = ((ot['qualified_name'] + '.' + name) if ot and ot.get('qualified_name') else None) or r.get('qualifiedName') or \ + (((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name) + sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md index 6fd14df6f..b423e757c 100644 --- a/skills/axiomcode/reference/schema.md +++ b/skills/axiomcode/reference/schema.md @@ -26,8 +26,8 @@ tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. | base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | | base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | | entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | -| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`) | -| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field: **not recorded** (`field_access` empty; `refs` `NAME_REFERENCE` / `FIELD` by name) | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | | call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | | why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | | strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | @@ -46,7 +46,8 @@ in Python: its dispatch set is `dispatch_candidates` (basis `mro`). | methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | | subtypes of a type | `impact `: "must change with it" | | who writes a Java field | `impact .`: "produces or writes it" | -| who writes a C# property | `impact :`: answers through its accessors. `impact Type.Prop` and `Type._field` find nothing in C# on this build (the symbols carry no owner): fall back to the SQL below | +| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | +| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | | a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | | text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | | a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | diff --git a/tests/cases/csharp/member-owner-is-its-type/case.json b/tests/cases/csharp/member-owner-is-its-type/case.json new file mode 100644 index 000000000..d2507329a --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/case.json @@ -0,0 +1,40 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a field is named by its declaring type, so Type.Field resolves to the field and lists its reader, not an undeclared type name called local (#1445)", + "run": ["impact", "AuditOptions.Limit"], + "want": ["change: field AuditOptions.Limit", "AuditService.Max src/App/AuditService.cs:8"], + "avoid": ["not declared here", "the change is local"]}, + {"why": "a property is named by its declaring type, so Type.Prop reaches its readers and writers through the accessors (#1445)", + "run": ["impact", "AuditOptions.Enabled"], + "want": ["change: field AuditOptions.Enabled", "[resolved] AuditService.IsOn src/App/AuditService.cs:7 — reads it through get_Enabled()", + "[resolved] AuditService.Enable src/App/AuditService.cs:10 — writes it through set_Enabled()"], + "avoid": ["not declared here", "[resolved] RetryService.RetryOn"]}, + {"why": "near-miss control: another type's same-named property is its own declaration, with its own resolved reader", + "run": ["impact", "RetryOptions.Enabled"], + "want": ["change: field RetryOptions.Enabled", "[resolved] RetryService.RetryOn src/App/RetryService.cs:7 — reads it through get_Enabled()"], + "avoid": ["[resolved] AuditService.IsOn", "[resolved] AuditService.Enable", "+1 declarations of that name"]}, + {"why": "a const is named by its declaring type (#1445)", + "run": ["impact", "AuditOptions.SectionName"], + "want": ["change: const AuditOptions.SectionName", "AuditService.Section src/App/AuditService.cs:9"], + "avoid": ["not declared here", "the change is local"]}, + {"why": "a private field is read in scope by its owner's methods, and by no other type", + "run": ["impact", "AuditService._o"], + "want": ["change: field AuditService._o", "[in scope] AuditService.Max"], + "avoid": ["RetryService", "not declared here"]}, + {"why": "a nested type's member is named through its outer type", + "run": ["impact", "Limits.Inner.Cap"], + "want": ["change: field Limits.Inner.Cap", "Limits.Read"], + "avoid": ["not declared here"]}, + {"why": "the bare name answers for both same-named fields, each under its own owner", + "run": ["impact", "Limit", "--kind", "field"], + "want": ["AuditOptions.Limit", "+1 declarations of that name"]}, + {"why": "a read of another type's same-named field is not this field's reader; needs the engine to resolve C# member accesses to fields (field_access is empty for C#), so today it is a [by name] row", + "pending": "C# field_access", + "run": ["impact", "AuditOptions.Limit"], + "avoid": ["RetryService.Attempts"]}, + {"why": "the declaration line of a field is the field, as a property's line is its accessors", + "pending": "file:line on a field declaration", "expect_error": true, + "run": ["impact", "src/App/Options.cs:7"], + "want": ["AuditService.Max"], + "avoid": ["no callable spans"]} + ]} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj b/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs new file mode 100644 index 000000000..1fbb3b3fd --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs @@ -0,0 +1,12 @@ +namespace App; + +public sealed class AuditService +{ + private readonly AuditOptions _o = new(); + + public bool IsOn() => _o.Enabled; + public int Max() => _o.Limit; + public string Section() => AuditOptions.SectionName; + public void Enable() { _o.Enabled = true; } + public AuditOptions Options() => _o; +} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs new file mode 100644 index 000000000..e744a07eb --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs @@ -0,0 +1,25 @@ +namespace App; + +public sealed class AuditOptions +{ + public const string SectionName = "Audit"; + public bool Enabled { get; set; } + public int Limit; +} + +public sealed class RetryOptions +{ + public const string SectionName = "Retry"; + public bool Enabled { get; set; } + public int Limit; +} + +public static class Limits +{ + public sealed class Inner + { + public int Cap; + } + + public static int Read(Inner i) => i.Cap; +} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs new file mode 100644 index 000000000..f0d21bae8 --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs @@ -0,0 +1,10 @@ +namespace App; + +public sealed class RetryService +{ + private readonly RetryOptions _r = new(); + + public bool RetryOn() => _r.Enabled; + public int Attempts() => _r.Limit; + public string RetrySection() => RetryOptions.SectionName; +} From fb452738993a518194bf869be3136627303680ef Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:04:31 -0700 Subject: [PATCH 20/37] typescript: a published module's classes, method-reference consts, namespaces, object literals and default arrows are entry points A module named by package.json rooted only its exported functions and arrow consts. The public members of an exported class (and the classes it inherits from), a const bound to a method reference (`instance.m`, `instance.m.bind(x)`, `Klass.staticM`) or holding a function by any holder route, the callables of an exported object literal, the exported functions of an exported namespace, and an anonymous `export default` arrow are now package_export roots too. `private` / `#name` members and non-exported namespace functions stay out. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/plugins/deep/tracer.ts | 2 +- .../src/plugins/logger.ts | 4 +- .../src/package.json | 11 +++ .../src/src/anon.ts | 2 + .../src/src/api.ts | 5 ++ .../src/src/bound.ts | 14 ++++ .../src/src/cls.ts | 14 ++++ .../src/src/index.test.ts | 8 ++ .../src/src/ns.ts | 6 ++ .../src/tsconfig.json | 4 + .../77-package-published-entries.edges | 2 +- .../77-package-published-entries.entries | 2 +- .../77-package-published-entries.fields | 1 + ...77-package-published-entries.fields-oracle | 8 +- .../79-package-published-members.edges | 4 + .../79-package-published-members.entries | 12 +++ .../79-package-published-members.fields | 0 ...79-package-published-members.fields-oracle | 7 ++ .../79-package-published-members.oracle | 1 + .../79-package-published-members.type-use | 3 + .../79-package-published-members.types-oracle | 7 ++ .../engine/config-resolution/entry-points.dl | 79 +++++++++++++++++++ .../typescript/engine/projections/methods.dl | 5 ++ graph/typescript/souffle/decls_all.dl | 6 ++ 24 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 graph/test/typescript/cases/79-package-published-members/src/package.json create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/anon.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/api.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/bound.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/cls.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/src/ns.ts create mode 100644 graph/test/typescript/cases/79-package-published-members/src/tsconfig.json create mode 100644 graph/test/typescript/expected/79-package-published-members.edges create mode 100644 graph/test/typescript/expected/79-package-published-members.entries create mode 100644 graph/test/typescript/expected/79-package-published-members.fields create mode 100644 graph/test/typescript/expected/79-package-published-members.fields-oracle create mode 100644 graph/test/typescript/expected/79-package-published-members.oracle create mode 100644 graph/test/typescript/expected/79-package-published-members.type-use create mode 100644 graph/test/typescript/expected/79-package-published-members.types-oracle diff --git a/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts b/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts index 08843e7d0..6e35d6fc2 100644 --- a/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts +++ b/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts @@ -1,5 +1,5 @@ // CONTROL: two segments below plugins/, which the one-segment `*` does not match, and -// imported (not called) by logger.ts, so it is not an unimported module either. +// imported (neither called nor published) by logger.ts, so it is not an unimported module either. // tracer must stay unreachable. export function tracer() { return 'trace'; diff --git a/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts b/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts index 87820c2c6..61c7f5d2a 100644 --- a/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts +++ b/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts @@ -1,7 +1,9 @@ // Published by the "./plugins/*" subpath pattern: `*` = logger. import { tracer } from './deep/tracer'; -export const traced = tracer; +// Reads tracer without publishing it: a published const bound to `tracer` itself would +// hand the function to a consumer, which is a root (79-package-published-members). +export const tracerName = tracer.name; export function logger(message: string) { return format(message); diff --git a/graph/test/typescript/cases/79-package-published-members/src/package.json b/graph/test/typescript/cases/79-package-published-members/src/package.json new file mode 100644 index 000000000..3c2dc9734 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/package.json @@ -0,0 +1,11 @@ +{ + "name": "published-members", + "private": false, + "exports": { + "./cls": "./dist/cls.js", + "./anon": "./dist/anon.js", + "./bound": "./dist/bound.js", + "./ns": "./dist/ns.js", + "./api": "./dist/api.js" + } +} diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts b/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts new file mode 100644 index 000000000..76897d35f --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts @@ -0,0 +1,2 @@ +// An anonymous default arrow of a published module. +export default (x: number) => x; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/api.ts b/graph/test/typescript/cases/79-package-published-members/src/src/api.ts new file mode 100644 index 000000000..2bd8a13c3 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/api.ts @@ -0,0 +1,5 @@ +// A published object literal of callables. +export const api = { + method() { return 1; }, + arrow: () => 2, +}; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts b/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts new file mode 100644 index 000000000..3020f7536 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts @@ -0,0 +1,14 @@ +// A published const bound to a method reference: the headline API of many libraries. +class Engine { + produce(x: number) { return x + 1; } + finish(x: number) { return x - 1; } + static make() { return new Engine(); } + // CONTROL: a method no published const names stays unrooted. + unused() { return 0; } +} +const engine = new Engine(); +export const produce = engine.produce; +export const finish = engine.finish.bind(engine); +export const make = Engine.make; +// CONTROL: a published const holding a plain value roots nothing. +export const version = 1; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts b/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts new file mode 100644 index 000000000..07a1ade90 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts @@ -0,0 +1,14 @@ +// An exported class of a published module: its public members are the API. +export class Store { + get() { return 1; } + static create() { return new Store(); } + // CONTROL: private and #private members are not reachable from a consumer. + private secret() { return 2; } + #hidden() { return 3; } +} + +// CONTROL: a class that is declared but not exported publishes nothing. +class Internal { + run() { return 4; } +} +export const internalCount = 1; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts b/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts new file mode 100644 index 000000000..d1102f74e --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts @@ -0,0 +1,8 @@ +// The project's own test imports each published module, so none is an unimported module. +import { Store } from './cls'; +import anon from './anon'; +import { produce } from './bound'; +import { Result } from './ns'; +import { api } from './api'; + +export const all = [Store, anon, produce, Result, api]; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts b/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts new file mode 100644 index 000000000..bb2a281cc --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts @@ -0,0 +1,6 @@ +// The exported functions of an exported namespace. +export namespace Result { + export function ok(x: number) { return { ok: true, x }; } + // CONTROL: a namespace function that is not exported is not published. + function hidden() { return 0; } +} diff --git a/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json b/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json new file mode 100644 index 000000000..7b6480a12 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "target": "es2020", "module": "esnext", "outDir": "dist" }, + "include": ["src"] +} diff --git a/graph/test/typescript/expected/77-package-published-entries.edges b/graph/test/typescript/expected/77-package-published-entries.edges index 434d7ba30..313a7e054 100644 --- a/graph/test/typescript/expected/77-package-published-entries.edges +++ b/graph/test/typescript/expected/77-package-published-entries.edges @@ -1,6 +1,6 @@ known_edge FUNCTION_CALL index#useStore(number) @L11 -> internal#describeState(number) known_edge FUNCTION_CALL index#useStore(number) @L9 -> vanilla#createStore(number) -known_edge FUNCTION_CALL plugins/logger#logger(string) @L7 -> plugins/logger#format(string) +known_edge FUNCTION_CALL plugins/logger#logger(string) @L9 -> plugins/logger#format(string) known_edge FUNCTION_CALL vanilla#createBoundStore(number) @L12 -> vanilla#seal(number) known_edge FUNCTION_CALL vanilla#createCastStore(number) @L18 -> vanilla#seal(number) known_edge FUNCTION_CALL vanilla#createStore(number) @L4 -> vanilla#seal(number) diff --git a/graph/test/typescript/expected/77-package-published-entries.entries b/graph/test/typescript/expected/77-package-published-entries.entries index c670ddf0d..eedc22bc3 100644 --- a/graph/test/typescript/expected/77-package-published-entries.entries +++ b/graph/test/typescript/expected/77-package-published-entries.entries @@ -1,6 +1,6 @@ ── entry_point (6) ── package_export index#useStore index.ts:8 - package_export plugins/logger#logger logger.ts:6 + package_export plugins/logger#logger logger.ts:8 package_export vanilla# vanilla.ts:12 package_export vanilla# vanilla.ts:18 package_export vanilla#createStore vanilla.ts:3 diff --git a/graph/test/typescript/expected/77-package-published-entries.fields b/graph/test/typescript/expected/77-package-published-entries.fields index 52a0bdc35..0233e0761 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields +++ b/graph/test/typescript/expected/77-package-published-entries.fields @@ -1 +1,2 @@ ambiguous_unknown read index#useStore(number) -> - +ambiguous_unknown read plugins/logger#() -> - diff --git a/graph/test/typescript/expected/77-package-published-entries.fields-oracle b/graph/test/typescript/expected/77-package-published-entries.fields-oracle index f7d053de2..85d59b992 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields-oracle +++ b/graph/test/typescript/expected/77-package-published-entries.fields-oracle @@ -1,8 +1,8 @@ 77-package-published-entries [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 1 resolved 0 (0.0%) - tiers ambiguous_unknown=1 - access read=1 - not scored: 1 rows whose target is not a client declaration + sites 2 resolved 0 (0.0%) + tiers ambiguous_unknown=2 + access read=2 + not scored: 2 rows whose target is not a client declaration MISSING index#useStore(number) READ vanilla#value diff --git a/graph/test/typescript/expected/79-package-published-members.edges b/graph/test/typescript/expected/79-package-published-members.edges new file mode 100644 index 000000000..d06a307fe --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.edges @@ -0,0 +1,4 @@ +ambiguous_unknown METHOD_CALL src/bound#() @L11 -> - +known_edge CONSTRUCTOR_CALL Engine#static make() @L5 -> Engine#() +known_edge CONSTRUCTOR_CALL Store#static create() @L4 -> Store#() +known_edge CONSTRUCTOR_CALL src/bound#() @L9 -> Engine#() diff --git a/graph/test/typescript/expected/79-package-published-members.entries b/graph/test/typescript/expected/79-package-published-members.entries new file mode 100644 index 000000000..90dc4e907 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.entries @@ -0,0 +1,12 @@ +── entry_point (11) ── + package_export src/anon# anon.ts:2 + package_export src/api# api.ts:4 + package_export src/api#method api.ts:3 + package_export src/bound#Engine.finish bound.ts:4 + package_export src/bound#Engine.make bound.ts:5 + package_export src/bound#Engine.produce bound.ts:3 + package_export src/cls#Store. cls.ts:2 + package_export src/cls#Store.create cls.ts:4 + package_export src/cls#Store.get cls.ts:3 + package_export src/ns#Result.ok ns.ts:3 + unimported_module src/index.test# index.test.ts:1 diff --git a/graph/test/typescript/expected/79-package-published-members.fields b/graph/test/typescript/expected/79-package-published-members.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/79-package-published-members.fields-oracle b/graph/test/typescript/expected/79-package-published-members.fields-oracle new file mode 100644 index 000000000..bf1e0d9b3 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.fields-oracle @@ -0,0 +1,7 @@ +79-package-published-members [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-package-published-members.oracle b/graph/test/typescript/expected/79-package-published-members.oracle new file mode 100644 index 000000000..a43ea4f88 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.oracle @@ -0,0 +1 @@ +oracle=3 engine=3 agree=3 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/79-package-published-members.type-use b/graph/test/typescript/expected/79-package-published-members.type-use new file mode 100644 index 000000000..51ae065ce --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.type-use @@ -0,0 +1,3 @@ +known_edge OBJECT_CREATION_TYPE 0 Engine [EXPRESSION] -> Engine +known_edge OBJECT_CREATION_TYPE 0 Store [EXPRESSION] -> Store +known_edge OBJECT_CREATION_TYPE 0 src/bound [EXPRESSION] -> Engine diff --git a/graph/test/typescript/expected/79-package-published-members.types-oracle b/graph/test/typescript/expected/79-package-published-members.types-oracle new file mode 100644 index 000000000..1485da222 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.types-oracle @@ -0,0 +1,7 @@ +79-package-published-members [types] + precision 1.0000 (3 correct, 0 wrong) + recall 1.0000 (3 of 3 the compiler resolved) + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + contexts OBJECT_CREATION_TYPE=3 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/typescript/engine/config-resolution/entry-points.dl b/graph/typescript/engine/config-resolution/entry-points.dl index 8aa02e1d2..168ff32a0 100644 --- a/graph/typescript/engine/config-resolution/entry-points.dl +++ b/graph/typescript/engine/config-resolution/entry-points.dl @@ -152,6 +152,85 @@ entry_point(m, "package_export") :- cfg_published_module(mod), en != "", !cfg_placeholder_name(en). +// ── …and the other ways a library publishes callable code (#1626) ─────────── +// An exported function or arrow const is one shape of a published API. The rest are just +// as common, and each left its code reading as safe to delete: +// * `export class Store { get() {} static create() {} }` — the consumer calls the +// class's members, constructor included, and every member it inherits; +// * `export const produce = engine.produce`, `= engine.finish.bind(engine)`, +// `= Engine.make` — a const bound to a method REFERENCE, the headline API of +// libraries that publish one configured instance; +// * `export const api = { get() {}, put: () => {} }` — an object literal of callables; +// * `export namespace Result { export function ok() {} }` — a namespace's exported +// functions; +// * `export default (x) => …` — an anonymous default arrow or function expression. +// +// Declined, rather than guessed: `private` and `#name` members (no consumer can name +// them), a namespace function without `export`, a method no published value names, and a +// published const holding anything that is not a function. +cfg_package_module(mod) :- cfg_published_module(mod), !entry_module(mod). + +cfg_published_export(mod, en, k, e) :- cfg_package_module(mod), + module_export_any("client", mod, en, k, e), + en != "", + !cfg_placeholder_name(en). + +// a published class, and every class it inherits from: `store.inherited()` is a call too +cfg_published_class(t) :- cfg_published_export(_, _, "TYPE", t), class_type(t). +cfg_published_class(anc) :- cfg_published_class(t), type_ancestor(t, anc), class_type(anc). + +cfg_member_hidden("PRIVATE_ACCESS"). +cfg_member_hidden("PRIVATE_NAME_ACCESS"). + +entry_point(m, "package_export") :- cfg_published_class(t), + method_owner("client", t, m), + method_access("client", acc, m), + !cfg_member_hidden(acc). + +entry_point(m, "package_export") :- cfg_published_export(_, _, "TYPE", ns), + type_decl("client", _, _, _, "NAMESPACE_TYPE", _, ns), + method_owner("client", ns, m), + method_access("client", "EXPORTED_ACCESS", m). + +// A value the package hands out: a published const's initializer (and each branch it may +// evaluate to), the expression of `export default `, and the receiver of a `.bind`. +cfg_published_value(x) :- cfg_published_export(_, _, "VARIABLE", v), + var_initializer("client", _, e, v), + e != "", + value_branch(e, x). +cfg_published_value(e) :- cfg_package_module(mod), + export_decl(_, "default", _, "DEFAULT_EXPRESSION", mod, h), + export_expression("client", e, h). +cfg_published_value(r) :- cfg_published_value(x), + expr_kind("client", "CALL_EXPRESSION", _, x), + call_callee_expr(_, x, ne), + property_access_name(ne, "bind"), + expr_child("client", ne, "RECEIVER", _, r). + +// the function it is: a written arrow or function expression, a named function, a static +// method read as a value (`Engine.make`) — a call node carries its CALLEE as its +// referenced entity, so a call is not the function it names +entry_point(m, "package_export") :- cfg_published_value(x), + !expr_kind("client", "CALL_EXPRESSION", _, x), + expr_callable(x, m), + method_prov(m, "client"). +// an instance method read as a value: `engine.produce`, typed through its receiver +entry_point(m, "package_export") :- cfg_published_value(x), + expr_kind("client", "PROPERTY_ACCESS", _, x), + property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, q), + expr_type(q, _, t), + method_in_scope(t, n, _, m), + method_prov(m, "client"). +// the callables of a published object literal +entry_point(m, "package_export") :- cfg_published_value(x), + objlit_member_fn(x, _, m). +// and what a published const holds by any route the holder rules follow: a function +// another holder hands over, or one a call returns (`export const store = create()`) +entry_point(m, "package_export") :- cfg_published_export(_, _, "VARIABLE", v), + holder_holds_function(v, m), + method_prov(m, "client"). + // ── DECORATOR-DRIVEN ROOTS (Nest, Angular) ────────────────────────────────── // The gap the section above described, now closed. projections/decorators.dl gives // annotation_on the same shape Java and Python already carry, so these three rules diff --git a/graph/typescript/engine/projections/methods.dl b/graph/typescript/engine/projections/methods.dl index 90997dac9..789734b7b 100644 --- a/graph/typescript/engine/projections/methods.dl +++ b/graph/typescript/engine/projections/methods.dl @@ -50,6 +50,11 @@ method_decl("lib", name, en, ret, h) :- lib_ts_method(name, _, _, _, _, _, _, _, method_owner("client", owner, h) :- ts_method(_, _, _, _, _, _, _, owner, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). method_owner("lib", owner, h) :- lib_ts_method(_, _, _, _, _, _, _, owner, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). +// ── method_access(Prov, Access, MethodHash) ───────────────────────────────── +// Col 10, TsMethodAccess: `private` and `#name` on a class member, EXPORTED_ACCESS or +// MODULE_LOCAL_ACCESS on a function (inside a namespace too), PUBLIC_ACCESS otherwise. +method_access("client", acc, h) :- ts_method(_, _, _, _, _, _, _, _, _, _, acc, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). + // ── method_module(Prov, ModuleHash, MethodHash) ───────────────────────────── // A top-level `function f()` has NO owning type, so col 7 is empty and the module // is the only scope it belongs to. In Java every method has an owner; here the diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index e5ffef4be..5b71b74ac 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -329,6 +329,7 @@ .decl method_lines(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_max_arity(c0:symbol,c1:number) .decl method_min_arity(c0:symbol,c1:number) +.decl method_access(c0:symbol,c1:symbol,c2:symbol) .decl method_module(c0:symbol,c1:symbol,c2:symbol) .decl method_module_scope(c0:symbol,c1:symbol) .decl method_owner(c0:symbol,c1:symbol,c2:symbol) @@ -605,6 +606,11 @@ .decl method_kind_is_assignable_signature(c0:symbol) .decl package_entry(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol) .decl cfg_published_module(c0:symbol) +.decl cfg_package_module(c0:symbol) +.decl cfg_published_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_published_class(c0:symbol) +.decl cfg_member_hidden(c0:symbol) +.decl cfg_published_value(c0:symbol) .decl bean_def(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl inject_point(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl di_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) From 4dca1f68bc711ce9e7b729b3ecd38c09ae38cc77 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:59:38 -0700 Subject: [PATCH 21/37] test-impact: each TypeScript/JavaScript test file gets the command its package runs it with (#1570) `npx vitest run ` was printed for every TS/JS selection, so a script suite (`src/test/x-tests.ts`, run as `npx tsx src/test/x-tests.ts`) got a command that exits 1 with "No test files found". Each file is now placed with the runner whose include globs collect it (vitest, jest, mocha: config file, package.json key, deps or scripts), run from its package; failing that, the package script, the file's own usage line, or the package README that runs it; failing that, it is named as collected by no runner rather than handed to one. The --json answer carries `not_collected`, and `next:` names every command. Control: a *.test.ts file the package's vitest collects keeps `npx vitest run`, and with nothing configured anywhere the command is unchanged; Python and Java commands are untouched. Closes #1570 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/ax_pages.py | 15 +- .../axiomcode/scripts/axiomcode-test-impact | 200 +++++++++++++++++- tests/README.md | 3 + .../test-command-per-runner/app/package.json | 5 + .../app/src/cart.test.ts | 8 + .../test-command-per-runner/app/src/cart.ts | 5 + .../app/src/test/cart-tests.ts | 12 ++ .../app/src/test/orphan-tests.ts | 7 + .../test-command-per-runner/case.json | 29 +++ .../typescript/test-command-per-runner/new.ts | 3 + .../typescript/test-command-per-runner/old.ts | 5 + tests/test_command.py | 124 +++++++++++ 12 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 tests/cases/typescript/test-command-per-runner/app/package.json create mode 100644 tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts create mode 100644 tests/cases/typescript/test-command-per-runner/app/src/cart.ts create mode 100644 tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts create mode 100644 tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts create mode 100644 tests/cases/typescript/test-command-per-runner/case.json create mode 100644 tests/cases/typescript/test-command-per-runner/new.ts create mode 100644 tests/cases/typescript/test-command-per-runner/old.ts create mode 100644 tests/test_command.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index a0d932d02..cf251a3f5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -195,11 +195,16 @@ def next_changed(text): return "next: `test-impact` names the tests this edit reaches and the command that runs exactly those; `impact ` for a signature or field change above" def next_test_impact(text): - # the LAST command printed: when a text tier adds the tests that load a changed fixture, it prints the command for - # both, and the first one alone left those tests out of the step an agent takes - ms = list(re.finditer(r'^\s*(?:with the tests above: )?((?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M)) - m = ms[-1] if ms else None - return f"next: run {m.group(1).strip()} — only the tests above; a test reached through reflection, a service loader or a subprocess is not among them" if m else '' + # a TypeScript selection can need one command per package and runner: `(cd pkg && npx tsx x.ts)` (#1570). + # When a text tier adds the tests that load a changed fixture, it prints the command(s) for both after + # "with the tests above:", and the first command alone left those tests out of the step an agent takes: the + # LAST such block wins, with every command that continues it + ms = list(re.finditer(r'^\s*(with the tests above: )?((?:\(cd \S+ && )?(?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pnpm|yarn|bun|node|tsx|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M)) + if not ms: return '' + last = max((i for i, m in enumerate(ms) if m.group(1)), default=0) + cmds = [m.group(2).strip() for m in ms[last:]] + what = cmds[0] if len(cmds) == 1 else f"the {len(cmds)} commands above, each from where it is written ({cmds[0]} …)" + return f"next: run {what} — only the tests above; a test reached through reflection, a service loader or a subprocess is not among them" NEXT = {'path': next_path, 'context': next_context, 'changed': next_changed, 'test-impact': next_test_impact} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 77817e7ed..c57d050c1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -121,7 +121,7 @@ def concrete_test_classes(db, names): # How a suite is invoked, per language. Only the shapes this graph is built for; anything else prints the # files and lets the reader decide, which is better than inventing a command that does not run. -def command_for(lang, files, classes, db=None): +def command_for(lang, files, classes, db=None, repo='.'): if lang in ('java', 'csharp') and db: # a class to select by name: the test's owner, else its file's basename (a Java or C# test class is its file) names = classes or [os.path.splitext(os.path.basename(f))[0] for f in files] @@ -147,10 +147,184 @@ def command_for(lang, files, classes, db=None): if lang == 'python': return "pytest " + " ".join(sorted(files)[:40]) if lang in ('typescript', 'javascript'): - return "npx vitest run " + " ".join(sorted(files)[:40]) + return "\n".join(js_plan(repo, files)[0]) or None return None +# A TypeScript or JavaScript test file is run by whatever its PACKAGE runs it with, and nothing about the +# language says which: vitest collects `*.test.ts` and ignores `services-tests.ts`, a script suite is run as +# `npx tsx src/test/x.ts` from its package, and `npx vitest run ` on a file vitest does not collect +# exits 1 with "No test files found" (#1570). So each file is placed with the runner that would collect it — +# read off the package's config and dependencies, with that runner's own include globs — and failing that +# with the command its package scripts or its own header give for it. A file nothing collects is said so, +# never handed to a runner that will not run it. +_RUNNERS = ( + # runner, config files, package.json key, default include (relative to the package), command + ('vitest', ('vitest.config.ts', 'vitest.config.mts', 'vitest.config.js', 'vitest.config.mjs', + 'vitest.config.cjs', 'vitest.config.cts', 'vitest.workspace.ts', 'vitest.workspace.js'), + None, ('**/*.{test,spec}.?(c|m)[jt]s?(x)',), 'npx vitest run'), + ('jest', ('jest.config.ts', 'jest.config.js', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'), + 'jest', ('**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'), 'npx jest'), + ('mocha', ('.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.jsonc', '.mocharc.yml', '.mocharc.yaml'), + 'mocha', ('test/*.{js,cjs,mjs}',), 'npx mocha'), +) +_INCLUDE_KEY = {'vitest': r'\binclude', 'jest': r'\btestMatch', 'mocha': r'\bspec'} + + +def _glob_re(g): + """A runner's include glob as a regex over a /-separated path: **, *, ?, [..], {a,b}, ?(..) and +(..).""" + out, i = '', 0 + g = g[2:] if g.startswith('./') else g + while i < len(g): + c = g[i] + if g.startswith('**/', i): + out += '(?:.*/)?'; i += 3; continue + if g.startswith('**', i): + out += '.*'; i += 2; continue + if c in '?+*@!' and g.startswith('(', i + 1): + j = g.index(')', i + 2) + alts = '|'.join(_glob_re(a) for a in g[i + 2:j].split('|')) + out += f"(?:{alts})" + {'?': '?', '+': '+', '*': '*', '@': '', '!': ''}[c]; i = j + 1; continue + if c == '*': + out += '[^/]*' + elif c == '?': + out += '[^/]' + elif c == '[': + j = g.index(']', i + 1); out += g[i:j + 1]; i = j + 1; continue + elif c == '{': + j = g.index('}', i + 1) + out += '(?:' + '|'.join(_glob_re(a) for a in g[i + 1:j].split(',')) + ')'; i = j + 1; continue + else: + out += re.escape(c) + i += 1 + return out + + +def _globs_match(globs, rel): + for g in globs: + try: + if re.fullmatch(_glob_re(g), rel): return True + except (ValueError, re.error): + return True # a glob this cannot read is not evidence the file is left out + return False + + +def _read(p, n=None): + try: + with open(p, encoding='utf-8', errors='replace') as fh: + return fh.read() if n is None else fh.read(n) + except OSError: + return '' + + +def _package_of_file(repo, rel): + """The directory (relative to repo, '' for the root) of the nearest package.json above a file.""" + d = os.path.dirname(rel) + while True: + if os.path.isfile(os.path.join(repo, d, 'package.json')): return d + if not d: return '' + d = os.path.dirname(d) + + +def _runners_of(repo, pkg): + """[(runner, include globs, command)] the package at `pkg` runs its tests with, most specific first.""" + pj_text = _read(os.path.join(repo, pkg, 'package.json')) + try: + pj = json.loads(pj_text) if pj_text else {} + except ValueError: + pj = {} + deps = {**(pj.get('dependencies') or {}), **(pj.get('devDependencies') or {})} + scripts = ' '.join(str(v) for v in (pj.get('scripts') or {}).values()) + out = [] + for name, configs, key, default, cmd in _RUNNERS: + conf = next((c for c in configs if os.path.isfile(os.path.join(repo, pkg, c))), None) + if not (conf or (key and key in pj) or name in deps or re.search(rf'\b{name}\b', scripts)): + continue + text = _read(os.path.join(repo, pkg, conf)) if conf else json.dumps(pj.get(key) or {}) if key else '' + globs = default + m = re.search(_INCLUDE_KEY[name] + r'["\']?\s*[:=]\s*(\[[^\]]*\]|["\'][^"\']+["\'])', text) + if m: + found = re.findall(r'["\']([^"\']+)["\']', m.group(1)) + globs = tuple(found) if found else ('**',) + elif re.search(_INCLUDE_KEY[name] + r'\b', text): + globs = ('**',) # an include this cannot read: assume the file is in it rather than out + out.append((name, globs, cmd)) + return out + + +def _script_for(repo, pkg, prel): + """The command a package script, or the file's own usage line, gives for running this one file.""" + pj_text = _read(os.path.join(repo, pkg, 'package.json')) + try: + scripts = (json.loads(pj_text).get('scripts') or {}) if pj_text else {} + except ValueError: + scripts = {} + for k, v in scripts.items(): + if re.search(r'(^|[\s/"\'])' + re.escape(prel) + r'($|[\s"\'])', str(v)): + return f"npm run {k}" + runner = r'(?:(?:npx|pnpm(?: exec)?|yarn|bunx)\s+)?(?:tsx|ts-node(?:-esm)?|node(?:\s+--[\w-]+(?:=\S+)?)*|bun(?: run)?|deno run(?:\s+-\S+)*)' + lead = r'(?:^|[\s*/#`$])(' + # the file's own usage line first, then the package's README: where a script suite says how it is run + head = _read(os.path.join(repo, pkg, prel), 4000) + for text, exact_only in ((head, False), (_read(os.path.join(repo, pkg, 'README.md')), True)): + m = re.search(lead + runner + r')\s+(?:\./)?' + re.escape(prel) + r'(?=$|[\s`\'"])', text, re.M) + if m: + return f"{m.group(1)} {prel}" + if exact_only: continue + # a header written from another directory (`npx tsx x-tests.ts` from beside it) names the file by basename + base = os.path.basename(prel) + m = re.search(lead + runner + r')\s+(\S*' + re.escape(base) + r')(?=$|[\s`\'"])', text, re.M) + if m and os.path.isfile(os.path.join(repo, pkg, m.group(2))): + return f"{m.group(1)} {m.group(2)}" + return None + + +def js_plan(repo, files): + """(commands, files no runner collects): each file with the runner its own package would run it with.""" + by_runner = collections.OrderedDict() + singles, unrun = [], [] + for f in sorted(files)[:40]: + pkg = _package_of_file(repo, f) + prel = f[len(pkg) + 1:] if pkg else f + runners = _runners_of(repo, pkg) + if not runners and pkg: + # a workspace member with no runner of its own is run by the root's, with paths from the root + root = _runners_of(repo, '') + if root: pkg, prel, runners = '', f, root + if not runners: + # nothing configured anywhere: a file named the way vitest and jest both collect keeps the command + # this always printed; any other file has no runner to be handed to + runners = [('vitest', _RUNNERS[0][3], _RUNNERS[0][4])] + runner = next(((n, c) for n, globs, c in runners if _globs_match(globs, prel)), None) + if runner: + by_runner.setdefault((pkg, runner[1]), []).append(prel); continue + cmd = _script_for(repo, pkg, prel) + if cmd: + singles.append((pkg, cmd)) + else: + unrun.append(f) + def cd(pkg, cmd): + return f"(cd {pkg} && {cmd})" if pkg else cmd + cmds = [cd(pkg, f"{c} " + " ".join(fs)) for (pkg, c), fs in by_runner.items()] + seen = set() + for pkg, c in singles: + if (pkg, c) not in seen: + seen.add((pkg, c)); cmds.append(cd(pkg, c)) + return cmds, unrun + + +def print_unrun(lang, repo, files): + """Say which selected files no runner collects, so a reader does not take the command as the whole run.""" + if lang not in ('typescript', 'javascript'): return + unrun = js_plan(repo, files)[1] + if unrun: + print(f"\n {len(unrun)} selected file(s) are collected by no test runner their package configures (no" + f" include glob matches, and no package script or usage line in the file runs it) — no command" + f" is given for them; read how each is run before trusting it ran:") + for f in unrun[:6]: + print(f" {f}") + + # The call graph can be silent for a reason that has nothing to do with coverage. A function placed in an # ARRAY or a table and invoked by iteration -- a transform pipeline, a middleware chain, a plugin list, a @@ -503,7 +677,7 @@ def main(argv): run_classes = classes + sorted({os.path.splitext(os.path.basename(r['at'].split(':')[0]))[0] for r in ownerless}) \ if classes and lang in ('java', 'csharp') else classes replaced = concrete_test_classes(db, run_classes)[1] if lang in ('java', 'csharp') else {} - cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db) + cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db, repo) if as_json: print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], @@ -513,6 +687,7 @@ def main(argv): 'edited_test_files': edited, 'command': cmd_all, 'abstract_replaced': replaced, + 'not_collected': js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else [], 'not_reached_from': unseen, 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], @@ -547,9 +722,11 @@ def main(argv): print(f" {f}") # `lang` is guessed from the files the graph named, and here it named none — so guess it from # the tier's own files, or the one runnable sentence this block exists to produce is empty. - cmd = command_for(lang or _guess_lang(nf), nf[:limit], [], db) + nlang = lang or _guess_lang(nf) + cmd = command_for(nlang, nf[:limit], [], db, repo) if cmd: - print(f"\n {cmd}") + print("\n " + cmd.replace("\n", "\n ")) + print_unrun(nlang, repo, nf[:limit]) if pkg_hits: n = sum(len(v) for v in pkg_hits.values()) print(f"\n BY PACKAGE ONLY — {n} test file(s) live under the same package as a changed file") @@ -559,7 +736,8 @@ def main(argv): for f in sorted(fs)[:limit]: print(f" {f}") if edited and cmd_all: - print(f"\n {cmd_all}") + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) elif tests: print(f"\ntests to run: {len(tests)} test(s) in {len(files)} file(s)") for f in files[:limit]: @@ -579,9 +757,11 @@ def main(argv): print(f"\n {len(each)} abstract test class(es) run nothing by name, so the command runs the classes that extend them: " + '; '.join(each[:4]) + (f"; +{len(each) - 4} more (--json: abstract_replaced)" if len(each) > 4 else '')) if cmd_all: - print(f"\n {cmd_all}") + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) elif edited and cmd_all: - print(f"\n {cmd_all}") + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) if outside or loaded: # NOT A CALL: a file no graph reads, or a program a test starts by name. The tests that name it are the lead if outside: @@ -592,8 +772,8 @@ def main(argv): for f, (n, ts) in sorted(loaded.items()): print(f" {f} named as '{n}' by: " + ", ".join(ts[:6]) + (f" … +{len(ts) - 6}" if len(ts) > 6 else "")) both = sorted(set(run_files) | set(loaded_files)) - cmd = command_for(_guess_lang(both) or lang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else [], db) - if cmd: print(f"\n with the tests above: {cmd}") + cmd = command_for(_guess_lang(both) or lang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else [], db, repo) + if cmd: print("\n with the tests above: " + cmd.replace("\n", "\n ")) missed = [f for f in outside if f not in loaded] if missed: print(f" no test names {', '.join(missed[:6])}{' …' if len(missed) > 6 else ''} in its text: which suite reads " diff --git a/tests/README.md b/tests/README.md index 49d8e3023..39445bbb1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -85,6 +85,9 @@ One check needs no graph and is its own script: answers from the interpreter and starts one background compile into the user cache, a second plugin copy reuses it, rules one line apart do not, an older plugin's dl/.cache binary is still used (#1606; needs soufflé and c++, no engine) + python3 tests/test_command.py the command test-impact prints for TypeScript/JavaScript runs those files: each with + the runner whose include globs collect it, else its package script, header or + package README, else named as collected by nothing (#1570; no engine) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/cases/typescript/test-command-per-runner/app/package.json b/tests/cases/typescript/test-command-per-runner/app/package.json new file mode 100644 index 000000000..93b2466ce --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/package.json @@ -0,0 +1,5 @@ +{ + "name": "app", + "scripts": { "test": "vitest" }, + "devDependencies": { "vitest": "^1.0.4", "tsx": "^4.7.0" } +} diff --git a/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts b/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts new file mode 100644 index 000000000..313aa3582 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { total } from './cart'; + +describe('cart', () => { + it('sums the prices', () => { + expect(total([1, 2, 3])).toBe(6); + }); +}); diff --git a/tests/cases/typescript/test-command-per-runner/app/src/cart.ts b/tests/cases/typescript/test-command-per-runner/app/src/cart.ts new file mode 100644 index 000000000..6f6c7827a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/cart.ts @@ -0,0 +1,5 @@ +export function total(prices: number[]): number { + let sum = 0; + for (const p of prices) sum += p; + return sum; +} diff --git a/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts b/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts new file mode 100644 index 000000000..d15f12451 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts @@ -0,0 +1,12 @@ +/** + * CART TESTS — a script suite, not collected by vitest. + * + * npx tsx src/test/cart-tests.ts + */ +import { total } from '../cart'; + +function testTotalSums(): void { + if (total([2, 2]) !== 4) throw new Error('total'); +} + +testTotalSums(); diff --git a/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts b/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts new file mode 100644 index 000000000..26273f164 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts @@ -0,0 +1,7 @@ +import { total } from '../cart'; + +function testTotalEmpty(): void { + if (total([]) !== 0) throw new Error('empty'); +} + +testTotalEmpty(); diff --git a/tests/cases/typescript/test-command-per-runner/case.json b/tests/cases/typescript/test-command-per-runner/case.json new file mode 100644 index 000000000..69397a58a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/case.json @@ -0,0 +1,29 @@ +{ + "lang": "typescript", + "src": "app", + "checks": [ + { + "why": "a script suite vitest does not collect (src/test/cart-tests.ts, run as `npx tsx src/test/cart-tests.ts` per its header) gets that command from its package, not `npx vitest run`; a file nothing runs is named as such (#1570)", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts"], + "want": [ + "(cd app && npx tsx src/test/cart-tests.ts)", + "collected by no test runner", + "app/src/test/orphan-tests.ts" + ], + "avoid": ["npx vitest run app/src/test/cart-tests.ts", "vitest run src/test/cart-tests.ts", "npx tsx src/test/orphan-tests.ts", "Traceback"] + }, + { + "why": "CONTROL: a *.test.ts file the package's vitest collects keeps `npx vitest run`, run from its package", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts"], + "want": ["(cd app && npx vitest run src/cart.test.ts)"], + "avoid": ["Traceback"] + }, + { + "why": "the --json answer carries the same commands and the files nothing collects", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts", "--json"], + "stdout_json": true, + "want": ["npx tsx src/test/cart-tests.ts", "\"not_collected\"", "app/src/test/orphan-tests.ts"], + "avoid": ["Traceback"] + } + ] +} diff --git a/tests/cases/typescript/test-command-per-runner/new.ts b/tests/cases/typescript/test-command-per-runner/new.ts new file mode 100644 index 000000000..23924a4f7 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/new.ts @@ -0,0 +1,3 @@ +export function total(prices: number[]): number { + return prices.reduce((a, b) => a + b, 0); +} diff --git a/tests/cases/typescript/test-command-per-runner/old.ts b/tests/cases/typescript/test-command-per-runner/old.ts new file mode 100644 index 000000000..6f6c7827a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/old.ts @@ -0,0 +1,5 @@ +export function total(prices: number[]): number { + let sum = 0; + for (const p of prices) sum += p; + return sum; +} diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 000000000..ff11292db --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""tests/test_command.py — the command test-impact prints for a TypeScript or JavaScript selection runs those files. + +`npx vitest run ` on a file vitest does not collect exits 1 with "No test files found" (#1570). Each file is +placed with the runner its own package would collect it with (that runner's include globs), else with the command +its package scripts, its own header or its package README give for it, else named as collected by nothing. Every +shape here has a control beside it: a file the runner does collect keeps the runner's command. No engine needed. +""" +import importlib.machinery, importlib.util, json, os, shutil, sys, tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPTS = os.path.join(os.path.dirname(HERE), 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +sys.path.insert(0, SCRIPTS) +loader = importlib.machinery.SourceFileLoader('ti', os.path.join(SCRIPTS, 'axiomcode-test-impact')) +ti = importlib.util.module_from_spec(importlib.util.spec_from_loader('ti', loader)); loader.exec_module(ti) +import ax_pages + +fails = 0 + + +def check(why, got, want): + global fails + if got != want: + fails += 1; print(f"FAIL {why}\n got: {got!r}\n want: {want!r}") + else: + print(f"ok {why}") + + +def tree(files): + d = tempfile.mkdtemp(prefix='ax-cmd-') + for rel, text in files.items(): + p = os.path.join(d, rel); os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').write(text if isinstance(text, str) else json.dumps(text)) + return d + + +VITEST_PKG = {'scripts': {'test': 'vitest'}, 'devDependencies': {'vitest': '1'}} + +# the defaults the runners document, read as globs +for g, path, want in [('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'src/a.test.ts', True), + ('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'a.spec.mjs', True), + ('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'src/test/a-tests.ts', False), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/a.test.tsx', True), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/test.ts', True), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/tests.ts', False), + ('**/__tests__/**/*.[jt]s?(x)', 'src/__tests__/a.ts', True), + ('test/*.{js,cjs,mjs}', 'test/a.js', True), + ('test/*.{js,cjs,mjs}', 'test/sub/a.js', False)]: + check(f"glob {g} {'matches' if want else 'does not match'} {path}", ti._globs_match((g,), path), want) + +# #1570: a script suite in a vitest package is run as its header says, from its package +d = tree({'parser/package.json': VITEST_PKG, + 'parser/src/test/a-tests.ts': '/**\n * npx tsx src/test/a-tests.ts # everything\n */\n', + 'parser/src/test/b-tests.ts': 'import x from "y";\n', + 'parser/README.md': '```\nnpx tsx src/test/b-tests.ts\n```\n', + 'parser/src/test/c-tests.ts': 'export {};\n', + 'parser/src/cart.test.ts': ''}) +cmds, unrun = ti.js_plan(d, ['parser/src/test/a-tests.ts', 'parser/src/test/b-tests.ts', + 'parser/src/test/c-tests.ts', 'parser/src/cart.test.ts']) +check("a vitest-collected file keeps vitest, run from its package (control)", + cmds[0], '(cd parser && npx vitest run src/cart.test.ts)') +check("a script suite gets its header's command", cmds[1], '(cd parser && npx tsx src/test/a-tests.ts)') +check("a script suite with no header gets its package README's command", cmds[2], + '(cd parser && npx tsx src/test/b-tests.ts)') +check("a file nothing runs is named, not handed to vitest", unrun, ['parser/src/test/c-tests.ts']) +check("no command names a file vitest does not collect", [c for c in cmds if 'vitest' in c and 'tests.ts' in c], []) +shutil.rmtree(d) + +# a package script that names the file +d = tree({'package.json': {'scripts': {'test:e2e': 'tsx e2e/run-e2e.ts'}, 'devDependencies': {'vitest': '1'}}, + 'e2e/run-e2e.ts': ''}) +check("a package script naming the file is the command", ti.js_plan(d, ['e2e/run-e2e.ts']), (['npm run test:e2e'], [])) +shutil.rmtree(d) + +# vitest with its own include: a file outside it is not vitest's, a file inside it is (control) +d = tree({'package.json': VITEST_PKG, + 'vitest.config.ts': "export default { test: { include: ['tests/**/*.ts'] } }\n", + 'tests/unit/a.ts': '', 'src/b.test.ts': ''}) +check("a configured vitest include collects what it names", + ti.js_plan(d, ['tests/unit/a.ts']), (['npx vitest run tests/unit/a.ts'], [])) +check("a configured vitest include leaves out a default-named file outside it", + ti.js_plan(d, ['src/b.test.ts']), ([], ['src/b.test.ts'])) +shutil.rmtree(d) + +# jest: its own runner, its own defaults +d = tree({'package.json': {'devDependencies': {'jest': '29'}}, 'src/__tests__/a.ts': '', 'src/b.spec.js': ''}) +check("jest collects __tests__ and *.spec with its own command", + ti.js_plan(d, ['src/__tests__/a.ts', 'src/b.spec.js']), (['npx jest src/__tests__/a.ts src/b.spec.js'], [])) +shutil.rmtree(d) + +# mocha: test/*.js by default +d = tree({'package.json': {'devDependencies': {'mocha': '10'}}, 'test/a.js': ''}) +check("mocha collects test/*.js with its own command", ti.js_plan(d, ['test/a.js']), (['npx mocha test/a.js'], [])) +shutil.rmtree(d) + +# a workspace member with no runner of its own is run by the root's +d = tree({'package.json': {'workspaces': ['pkgs/*'], 'devDependencies': {'vitest': '1'}}, + 'pkgs/core/package.json': {'name': 'core'}, 'pkgs/core/src/a.test.ts': ''}) +check("a workspace member without a runner is run by the root's, with the root path", + ti.js_plan(d, ['pkgs/core/src/a.test.ts']), (['npx vitest run pkgs/core/src/a.test.ts'], [])) +shutil.rmtree(d) + +# control: nothing configured anywhere, a *.test.ts file still gets the command it always got +d = tree({'src/a.test.ts': ''}) +check("nothing configured: a *.test.ts file keeps `npx vitest run` (control)", + ti.command_for('typescript', ['src/a.test.ts'], [], d), 'npx vitest run src/a.test.ts') +shutil.rmtree(d) + +# control: other languages are untouched +check("python unchanged (control)", ti.command_for('python', ['tests/test_a.py'], []), 'pytest tests/test_a.py') +check("java unchanged (control)", ti.command_for('java', [], ['app.ATest']), 'mvn test -Dtest=ATest') + +# the next: line names every command, and still names a single one as it did +check("next: one command is named as before (control)", + ax_pages.next_test_impact("\n npx vitest run src/a.test.ts\n").split(' — ')[0], 'next: run npx vitest run src/a.test.ts') +check("next: a (cd pkg && …) command is recognised", + ax_pages.next_test_impact("\n (cd parser && npx tsx src/test/a-tests.ts)\n").split(' — ')[0], + 'next: run (cd parser && npx tsx src/test/a-tests.ts)') +check("next: several commands are counted", + ax_pages.next_test_impact("\n (cd p && npx tsx a.ts)\n (cd p && npx tsx b.ts)\n").startswith('next: run the 2 commands above'), + True) + +print('\n' + ('all passed' if not fails else f"{fails} FAILED")) +sys.exit(1 if fails else 0) From 40965b188740dcb41c333ecd272408f3b5c81571 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:56:07 -0700 Subject: [PATCH 22/37] test-impact: read each TS/JS runner's config the way that runner reads it (#1570) The per-file runner placement read the first `include`/`testMatch` in a config file, so several shapes lost a command that ran or got one that does not: - vitest: the test include is the one under `test:` (not `coverage.include`); each inline project collects on its own; `exclude` takes files back out; vite.config.* is the config when there is no vitest.config.*; a workspace member is read from its own config or with vitest's defaults. - jest: testMatch is relative to , testRegex replaces it, rootDir/roots/ testPathIgnorePatterns are applied, and `jest --config x` in a script is a config too; a glob like `.[jt]s?(x)` is read whole, and a nested e2e/jest.config.ts runs its own files. - playwright (testDir/testMatch), `node --test` / `tsx --test` scripts and react-scripts get their own commands. - a runner with a config outranks one that is only a dependency; with a package.json and no runner nothing is handed to vitest; a lint script naming the file is not its test command; a test script's own glob can run it; paths with spaces are quoted. Control: each shape has a nearby case that keeps its command (a file outside the include, a default-named file under testRegex, a vitest config beside a jest dependency, a lint glob). Closes #1570 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-test-impact | 436 ++++++++++++++++-- tests/README.md | 4 +- tests/test_command.py | 115 +++++ 3 files changed, 507 insertions(+), 48 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index c57d050c1..d853edc02 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -54,7 +54,7 @@ those five files. Nothing a reader is shown got better; what got better is WHY t the graph can follow rather than a file named after the changed module. A recall number that does not move is not the same as a change that does nothing, and the rung on each row is where the difference is. """ -import importlib.machinery, importlib.util, json, os, re, subprocess, sys, collections +import importlib.machinery, importlib.util, json, os, re, subprocess, sys, collections, functools, posixpath, shlex TESTY = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', re.I) @@ -158,17 +158,105 @@ def command_for(lang, files, classes, db=None, repo='.'): # read off the package's config and dependencies, with that runner's own include globs — and failing that # with the command its package scripts or its own header give for it. A file nothing collects is said so, # never handed to a runner that will not run it. +# +# A runner's collection is read the way the runner reads it, not by the first `include` in the file: vitest's +# test include is the one directly under `test:` (not `coverage.include`), each inline project collects on its +# own, `exclude` takes files back out, and vite.config.* is its config when there is no vitest.config.*; jest's +# testMatch is relative to , testRegex replaces it, and `jest --config x` in a script is a config too. +# The nearest directory whose runner collects the file is where the command runs from, so a nested +# e2e/jest.config.ts runs its own files. A runner with a config outranks one that is only a dependency. +_CONF_EXT = ('ts', 'mts', 'cts', 'js', 'mjs', 'cjs') _RUNNERS = ( - # runner, config files, package.json key, default include (relative to the package), command - ('vitest', ('vitest.config.ts', 'vitest.config.mts', 'vitest.config.js', 'vitest.config.mjs', - 'vitest.config.cjs', 'vitest.config.cts', 'vitest.workspace.ts', 'vitest.workspace.js'), - None, ('**/*.{test,spec}.?(c|m)[jt]s?(x)',), 'npx vitest run'), - ('jest', ('jest.config.ts', 'jest.config.js', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'), - 'jest', ('**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'), 'npx jest'), + # runner, config files, package.json key, command + ('vitest', tuple(f'vitest.config.{e}' for e in _CONF_EXT) + tuple(f'vitest.workspace.{e}' for e in _CONF_EXT) + + ('vitest.workspace.json',), None, 'npx vitest run'), + ('jest', tuple(f'jest.config.{e}' for e in _CONF_EXT) + ('jest.config.json',), 'jest', 'npx jest'), ('mocha', ('.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.jsonc', '.mocharc.yml', '.mocharc.yaml'), - 'mocha', ('test/*.{js,cjs,mjs}',), 'npx mocha'), + 'mocha', 'npx mocha'), + ('playwright', tuple(f'playwright.config.{e}' for e in _CONF_EXT), None, 'npx playwright test'), ) -_INCLUDE_KEY = {'vitest': r'\binclude', 'jest': r'\btestMatch', 'mocha': r'\bspec'} +_VITEST_INCLUDE = ('**/*.{test,spec}.?(c|m)[jt]s?(x)',) +_VITEST_EXCLUDE = ('**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**') +_JEST_MATCH = ('**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)') +_MOCHA_SPEC = ('test/*.{js,cjs,mjs}',) +_PW_MATCH = ('**/*.@(spec|test).?(c|m)[jt]s?(x)',) +_NODE_TEST = ('**/*.test.?(c|m)[jt]s', '**/*-test.?(c|m)[jt]s', '**/*_test.?(c|m)[jt]s', '**/test-*.?(c|m)[jt]s', + '**/test.?(c|m)[jt]s', '**/test/**/*.?(c|m)[jt]s') +_CRA_MATCH = ('src/**/__tests__/**/*.{js,jsx,ts,tsx}', 'src/**/*.{spec,test}.{js,jsx,ts,tsx}') + +_JS_TOK = re.compile(r"""(?P\s+|//[^\n]*|/\*.*?\*/) + |(?P"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`) + |(?P[A-Za-z_$][\w$]*|\d[\w.]*) + |(?P

\.\.\.|.)""", re.S | re.X) +_JS_RE = re.compile(r'/((?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)/[a-z]*') + + +def _unq(s): + """A JS or JSON string literal's text.""" + return re.sub(r'\\(.)', lambda m: {'n': '\n', 't': '\t'}.get(m.group(1), m.group(1)), s[1:-1]) + + +def _js_keys(text, keys): + """Every `key: value` in a JS or JSON config whose key is in `keys`, as (path, key, value, owner, parents). + + path is the chain of keys enclosing it, owner the id of the object it sits in, parents each object's + enclosing object. value is a list of the string (or regex, as ('re', src)) literals, or None when the value + is not a literal this can read — a reader must then assume the file is in, not out.""" + toks, i, n = [], 0, len(text) + while i < n: + prev = toks[-1][1] if toks else '' + if text[i] == '/' and prev in (':', '[', ',', '(', '=') and not text.startswith(('//', '/*'), i): + m = _JS_RE.match(text, i) + if m: + toks.append(('re', m.group(1))); i = m.end(); continue + m = _JS_TOK.match(text, i) + kind = m.lastgroup + if kind != 'ws': + toks.append((kind, m.group(0))) + i = m.end() + out, stack, parents, nid, j = [], [], {}, 0, 0 + collecting = None + while j < len(toks): + kind, v = toks[j] + if kind in ('id', 's') and j + 1 < len(toks) and toks[j + 1][1] == ':' and stack and stack[-1][0] == '{': + key = _unq(v) if kind == 's' else v + owner = stack[-1][2] + path = tuple(k for _, k, _ in stack if k) + j += 2 + nxt = toks[j] if j < len(toks) else ('', '') + if key in keys: + if nxt[0] == 's': + out.append((path, key, [_unq(nxt[1])], owner, parents)) + elif nxt[0] == 're': + out.append((path, key, [('re', nxt[1])], owner, parents)) + elif nxt[1] == '[': + collecting = (len(stack) + 1, []) + out.append((path, key, collecting[1], owner, parents)) + else: + out.append((path, key, None, owner, parents)) + if nxt[1] in '{[(' and nxt[1]: + stack.append((nxt[1], key, None)); j += 1 + if nxt[1] == '{': + nid += 1; parents[nid] = next((o for b, _, o in reversed(stack[:-1]) if b == '{'), None) + stack[-1] = ('{', key, nid) + if key in keys: out[-1] = (path, key, ('obj', nid), owner, parents) + continue + if kind == 's' and stack and stack[-1][0] == '[' and all(b != '{' for b, _, _ in stack): + out.append(((), '', [_unq(v)], None, parents)) # a bare list entry: a workspace member + if v in ('{', '[', '('): + oid = None + if v == '{': + nid += 1; oid = nid; parents[nid] = next((o for b, _, o in reversed(stack) if b == '{'), None) + stack.append((v, None, oid)) + elif v in ('}', ']', ')'): + if collecting and len(stack) == collecting[0]: + collecting = None + if stack: stack.pop() + elif collecting and len(stack) == collecting[0]: + if kind == 's': collecting[1].append(_unq(v)) + elif kind == 're': collecting[1].append(('re', v)) + j += 1 + return out def _glob_re(g): @@ -226,32 +314,274 @@ def _package_of_file(repo, rel): d = os.path.dirname(d) -def _runners_of(repo, pkg): - """[(runner, include globs, command)] the package at `pkg` runs its tests with, most specific first.""" - pj_text = _read(os.path.join(repo, pkg, 'package.json')) +def _pkg_json(repo, d): + """The package.json in `d` as a dict, {} when unreadable, None when there is none.""" + t = _read(os.path.join(repo, d, 'package.json')) + if not t: return None try: - pj = json.loads(pj_text) if pj_text else {} + pj = json.loads(t) + return pj if isinstance(pj, dict) else {} except ValueError: - pj = {} - deps = {**(pj.get('dependencies') or {}), **(pj.get('devDependencies') or {})} - scripts = ' '.join(str(v) for v in (pj.get('scripts') or {}).values()) + return {} + + +def _under(rel, base): + """rel taken relative to base (both from the same directory), or None when it is not under it.""" + base = posixpath.normpath(base or '.') + if base == '.': return rel + r = posixpath.relpath(rel, base) + return None if r.startswith('../') or r == '..' else r + + +def _lit(v): + """A key's literal value as a list: [] when absent, None when it is there but not a literal.""" + return v if v is None or isinstance(v, list) else None + + +def _pats_match(pats, rel, glob_rel=None): + """Whether any glob (matched on glob_rel) or regex (searched in '/'+rel) of `pats` takes this file.""" + for p in pats: + if isinstance(p, tuple): + try: + if re.search(p[1], '/' + rel): return True + except re.error: + return True + elif _globs_match((p,), rel if glob_rel is None else glob_rel): + return True + return False + + +def _member_dirs(entries, rel): + """The directories on rel's path that a list of workspace / project globs names.""" + parts, out = rel.split('/')[:-1], [] + for e in entries: + if not isinstance(e, str): continue + e = e.replace('/', '').rstrip('/') + e = e[2:] if e.startswith('./') else e + if re.search(r'\.(c|m)?[jt]s$|\.json$', e): e = posixpath.dirname(e) + for k in range(1, len(parts) + 1): + pre = '/'.join(parts[:k]) + if _globs_match((e,), pre): out.append(pre) + return out + + +def _vitest_collects(texts, repo, d): + """How a vitest config (and workspace file) collects: a function of the path from its directory.""" + projects = [] # (root, include, exclude) — one per test block, each collecting on its own + members = [] + for t in texts: + recs = _js_keys(t, {'test', 'include', 'exclude', 'root', 'projects', 'workspace'}) + tests = [r[2][1] for r in recs if r[1] == 'test' and isinstance(r[2], tuple)] + for tid in tests: + own = {r[1]: r[2] for r in recs if r[3] == tid} + if 'projects' in own or 'workspace' in own: + members += [e for k in ('projects', 'workspace') for e in (own.get(k) or [])] + continue + root = own.get('root') or next((r[2] for r in recs if r[1] == 'root' and r[3] == r[4].get(tid)), None) + projects.append((root[0] if isinstance(root, list) and root else '.', + own.get('include', _VITEST_INCLUDE), own.get('exclude') or [])) + members += [e for r in recs if r[1] == '' for e in r[2]] + if not projects and not members: + projects.append(('.', _VITEST_INCLUDE, [])) + + def collects(rel): + for root, inc, exc in projects: + r = _under(rel, root) + if r is None: continue + if _pats_match(_VITEST_EXCLUDE, r) or _pats_match(exc, r): continue + # an include this cannot read (a variable, only spreads) is not evidence the file is left out + if not isinstance(inc, (list, tuple)) or not inc or _pats_match(inc, r): return True + for m in _member_dirs(members, rel): + # a member with a vitest config of its own is read from there; one without collects vitest's defaults + if any(os.path.isfile(os.path.join(repo, d, m, c)) for c in _RUNNERS[0][1]): continue + if _pats_match(_VITEST_INCLUDE, _under(rel, m)): return True + return False + return collects + + +def _jest_collects(text, base, repo, d): + """How a jest config at `base` (from the runner's directory `d`) collects.""" + keys = {'testMatch', 'testRegex', 'rootDir', 'roots', 'testPathIgnorePatterns', 'projects'} + recs = _js_keys(text, keys) + top = [r for r in recs if r[1] == 'projects'] + owners = collections.OrderedDict() + for r in recs: + if r[1] in keys and r[1] != 'projects': + owners.setdefault(r[3], {})[r[1]] = r[2] + root_opts = {} + if top: + root_opts = owners.pop(top[0][3], {}) + groups = list(owners.values()) + members = top[0][2] or [] + else: + merged = {} + for o in owners.values(): + for k, v in o.items(): merged.setdefault(k, v) + groups, members = [merged], [] + + def one(o, rel): + rd = o.get('rootDir', root_opts.get('rootDir')) + rd = rd[0] if isinstance(rd, list) and rd and isinstance(rd[0], str) else '.' + r = _under(rel, posixpath.join(base, rd.replace('', '.'))) + if r is None: return False + roots = o.get('roots') + if isinstance(roots, list) and roots: + if not any(_under(r, x.replace('', '.')) is not None for x in roots if isinstance(x, str)): + return False + ign = o.get('testPathIgnorePatterns') + for p in (ign if isinstance(ign, list) else ['/node_modules/']): + try: + if isinstance(p, str) and re.search(p.replace('', ''), '/' + r): return False + except re.error: + pass + if 'testRegex' in o: + v = o['testRegex'] + return True if v is None else _pats_match([p if isinstance(p, tuple) else ('re', p) for p in v], r) + m = o.get('testMatch', list(_JEST_MATCH)) + if m is None: return True + return _pats_match([p.replace('/', '') if isinstance(p, str) else p for p in m], r) + + def collects(rel): + if any(one(o, rel) for o in groups): return True + return any(_pats_match(_JEST_MATCH, _under(rel, m)) for m in _member_dirs(members, rel) + if not any(os.path.isfile(os.path.join(repo, d, m, c)) for c in _RUNNERS[1][1])) + return collects + + +def _playwright_collects(text): + recs = _js_keys(text, {'testDir', 'testMatch', 'testIgnore'}) + dirs = [v[0] for _, k, v, _, _ in recs if k == 'testDir' and isinstance(v, list) and v] or ['.'] + match = [p for _, k, v, _, _ in recs if k == 'testMatch' for p in (v or ['**'])] or list(_PW_MATCH) + ignore = [p for _, k, v, _, _ in recs if k == 'testIgnore' for p in (v or [])] + + def collects(rel): + for dr in dirs: + r = _under(rel, dr) + if r is not None and not (ignore and _pats_match(ignore, r)) and _pats_match(match, r): return True + return False + return collects + + +def _script_words(v): + """Each shell command in a package script as its words; a quoted command (`glob -c "tsx --test"`) is split too.""" out = [] - for name, configs, key, default, cmd in _RUNNERS: - conf = next((c for c in configs if os.path.isfile(os.path.join(repo, pkg, c))), None) - if not (conf or (key and key in pj) or name in deps or re.search(rf'\b{name}\b', scripts)): - continue - text = _read(os.path.join(repo, pkg, conf)) if conf else json.dumps(pj.get(key) or {}) if key else '' - globs = default - m = re.search(_INCLUDE_KEY[name] + r'["\']?\s*[:=]\s*(\[[^\]]*\]|["\'][^"\']+["\'])', text) - if m: - found = re.findall(r'["\']([^"\']+)["\']', m.group(1)) - globs = tuple(found) if found else ('**',) - elif re.search(_INCLUDE_KEY[name] + r'\b', text): - globs = ('**',) # an include this cannot read: assume the file is in it rather than out - out.append((name, globs, cmd)) + for seg in re.split(r'&&|\|\||;|\|', v): + words = [] + for q in re.findall(r'"([^"]*)"|\'([^\']*)\'|(\S+)', seg): + words += next((x for x in q if x), '').split() + out.append(words) return out +def _node_test(words): + """(command, include globs) for a `node --test` / `tsx --test` command line, or None.""" + for i, w in enumerate(words): + if posixpath.basename(w) not in ('node', 'tsx'): continue + k, opts = i + 1, [w] + while k < len(words) and words[k].startswith('-'): + opts.append(words[k]) + if words[k] in ('--import', '-r', '--require', '--loader', '--experimental-loader') and k + 1 < len(words): + k += 1; opts.append(words[k]) + k += 1 + if '--test' not in opts: continue + globs = [] + for x in words[k:]: + if x.startswith('-'): continue + x = x[2:] if x.startswith('./') else x + if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', x): globs.append(x) + elif '/' in x or re.fullmatch(r'[\w.-]+', x): globs.append(x.rstrip('/') + '/**/*.?(c|m)[jt]s') + cmd = 'npx tsx --test' if posixpath.basename(w) == 'tsx' else ' '.join(opts) + return cmd, tuple(globs) or _NODE_TEST + return None + + +@functools.lru_cache(maxsize=None) +def _runners_of(repo, d): + """[(runner, collects(path from d), command)] the directory `d` runs tests with, the configured ones first.""" + pj = _pkg_json(repo, d) or {} + deps = {**(pj.get('dependencies') or {}), **(pj.get('devDependencies') or {})} + scripts = [str(v) for v in (pj.get('scripts') or {}).values()] + said = ' '.join(scripts) + found = [] # (strength, order, name, collects, command) + + def conf_of(configs): + return next((c for c in configs if os.path.isfile(os.path.join(repo, d, c))), None) + + # vitest: vitest.config.* and the workspace file, else vite.config.* when vitest is what the package uses + vconfs = [c for c in _RUNNERS[0][1] if os.path.isfile(os.path.join(repo, d, c))] + uses_vitest = 'vitest' in deps or re.search(r'\bvitest\b', said) + vite = conf_of(tuple(f'vite.config.{e}' for e in _CONF_EXT)) + if not any(c.startswith('vitest.config.') for c in vconfs) and vite and uses_vitest: + vconfs.append(vite) + if vconfs or uses_vitest: + texts = [_read(os.path.join(repo, d, c)) for c in vconfs] + strength = 3 if vconfs and any(re.search(r'\btest\s*:', t) or c.startswith('vitest.') for c, t in + zip(vconfs, texts)) else 2 if re.search(r'\bvitest\b', said) else 1 + found.append((strength, 0, 'vitest', _vitest_collects(texts, repo, d), 'npx vitest run')) + + # jest: its config file or package.json key, each `jest --config x` a script runs, react-scripts' own jest + jconf = conf_of(_RUNNERS[1][1]) + uses_jest = 'jest' in deps or re.search(r'\bjest\b', said) + if jconf or 'jest' in pj: + text = _read(os.path.join(repo, d, jconf)) if jconf else json.dumps({'x': pj.get('jest')}) + found.append((3, 1, 'jest', _jest_collects(text, '.', repo, d), 'npx jest')) + elif uses_jest: + found.append((2 if re.search(r'\bjest\b', said) else 1, 1, 'jest', _jest_collects('', '.', repo, d), 'npx jest')) + for s in scripts: + m = re.search(r'\bjest\b[^&|;]*?(?:--config[= ]|-c\s+)(["\']?)([^\s"\'{]+)\1', s) + if m and os.path.isfile(os.path.join(repo, d, m.group(2))): + p = m.group(2) + base = posixpath.dirname(p[2:] if p.startswith('./') else p) or '.' + found.append((3, 1, 'jest', _jest_collects(_read(os.path.join(repo, d, p)), base, repo, d), + f'npx jest --config {shlex.quote(p)}')) + if re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s): + tool = re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s).group(1) + found.append((2, 1, 'jest', lambda rel: _pats_match(_CRA_MATCH, rel), + f'npx {tool} test --watchAll=false')) + + # mocha: .mocharc spec, the package.json key, or the specs its script names + mconf = conf_of(_RUNNERS[2][1]) + if mconf or 'mocha' in pj or 'mocha' in deps or re.search(r'\bmocha\b', said): + text = _read(os.path.join(repo, d, mconf)) if mconf else json.dumps({'x': pj.get('mocha') or {}}) + if mconf and mconf.endswith(('.yml', '.yaml')): + m = re.search(r'^spec:\s*(.*)$((?:\n\s+-.*)*)', text, re.M) + spec = ([s.strip(' \'"-') for s in ([m.group(1)] if m.group(1).strip() else []) + m.group(2).split('\n') + if s.strip(' \'"-')] if m else []) + else: + spec = next((r[2] for r in _js_keys(text, {'spec'})), []) + for words in (_script_words(s) for s in scripts): + for ws in words: + if 'mocha' in [posixpath.basename(w) for w in ws]: + i = [posixpath.basename(w) for w in ws].index('mocha') + rec = '--recursive' in ws + for x in ws[i + 1:]: + if x.startswith('-') or spec is None: continue + x = x[2:] if x.startswith('./') else x + if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', x): spec.append(x) + elif re.fullmatch(r'[\w./-]+', x): + spec.append(x.rstrip('/') + ('/**/*.{js,cjs,mjs}' if rec else '/*.{js,cjs,mjs}')) + spec = None if spec is None else [s[2:] if isinstance(s, str) and s.startswith('./') else s for s in spec] + found.append((3 if mconf or 'mocha' in pj else 2 if re.search(r'\bmocha\b', said) else 1, 2, 'mocha', + (lambda rel, spec=spec: True if spec is None else _pats_match(spec or _MOCHA_SPEC, rel)), + 'npx mocha')) + + # node's own runner: `node --test` / `tsx --test` in a script + for s in scripts: + for ws in _script_words(s): + nt = _node_test(ws) + if nt: + found.append((2, 3, 'node', (lambda rel, g=nt[1]: _pats_match(g, rel)), nt[0])) + + # playwright + pconf = conf_of(_RUNNERS[3][1]) + if pconf or '@playwright/test' in deps or 'playwright' in deps or re.search(r'\bplaywright\s+test\b', said): + found.append((3 if pconf else 2 if re.search(r'\bplaywright\s+test\b', said) else 1, 4, 'playwright', + _playwright_collects(_read(os.path.join(repo, d, pconf)) if pconf else ''), + 'npx playwright test')) + found.sort(key=lambda x: (-x[0], x[1])) + return [(name, collects, cmd) for _, _, name, collects, cmd in found] + + def _script_for(repo, pkg, prel): """The command a package script, or the file's own usage line, gives for running this one file.""" pj_text = _read(os.path.join(repo, pkg, 'package.json')) @@ -260,8 +590,17 @@ def _script_for(repo, pkg, prel): except ValueError: scripts = {} for k, v in scripts.items(): - if re.search(r'(^|[\s/"\'])' + re.escape(prel) + r'($|[\s"\'])', str(v)): - return f"npm run {k}" + # a script that RUNS the file, not one that lints, formats or type-checks it + if (re.search(r'(^|[\s/"\'])' + re.escape(prel) + r'($|[\s"\'])', str(v)) + and re.search(r'\b(tsx|ts-node(-esm)?|node|bun|deno|jest|vitest|mocha|playwright|ava|tap|uvu|cypress)\b', + str(v))): + return f"npm run {shlex.quote(k)}" + for k, v in scripts.items(): + # a test script whose own glob takes the file (`tape-runner test/test-*.js`) runs it, with the rest + if re.search(r'test|spec|e2e', k) and any(re.search(r'[*?]', w) and _globs_match((w[2:] if w.startswith('./') + else w,), prel) + for ws in _script_words(str(v)) for w in ws): + return f"npm run {shlex.quote(k)}" runner = r'(?:(?:npx|pnpm(?: exec)?|yarn|bunx)\s+)?(?:tsx|ts-node(?:-esm)?|node(?:\s+--[\w-]+(?:=\S+)?)*|bun(?: run)?|deno run(?:\s+-\S+)*)' lead = r'(?:^|[\s*/#`$])(' # the file's own usage line first, then the package's README: where a script suite says how it is run @@ -283,29 +622,32 @@ def js_plan(repo, files): """(commands, files no runner collects): each file with the runner its own package would run it with.""" by_runner = collections.OrderedDict() singles, unrun = [], [] + repo = os.path.realpath(repo) for f in sorted(files)[:40]: - pkg = _package_of_file(repo, f) - prel = f[len(pkg) + 1:] if pkg else f - runners = _runners_of(repo, pkg) - if not runners and pkg: - # a workspace member with no runner of its own is run by the root's, with paths from the root - root = _runners_of(repo, '') - if root: pkg, prel, runners = '', f, root - if not runners: - # nothing configured anywhere: a file named the way vitest and jest both collect keeps the command + # the nearest directory whose runner collects the file runs it: a member's own config first, then the + # root's (a workspace member with no runner of its own is run by the root's, with paths from the root) + hit, any_pkg, d = None, False, os.path.dirname(f) + while hit is None: + any_pkg = any_pkg or _pkg_json(repo, d) is not None + drel = f[len(d) + 1:] if d else f + hit = next(((d, drel, c) for _, collects, c in _runners_of(repo, d) if collects(drel)), None) + if not d: break + d = os.path.dirname(d) + if hit is None and not any_pkg and _globs_match(_VITEST_INCLUDE, f): + # no package anywhere above it: a file named the way vitest and jest both collect keeps the command # this always printed; any other file has no runner to be handed to - runners = [('vitest', _RUNNERS[0][3], _RUNNERS[0][4])] - runner = next(((n, c) for n, globs, c in runners if _globs_match(globs, prel)), None) - if runner: - by_runner.setdefault((pkg, runner[1]), []).append(prel); continue - cmd = _script_for(repo, pkg, prel) + hit = ('', f, _RUNNERS[0][3]) + if hit: + by_runner.setdefault((hit[0], hit[2]), []).append(hit[1]); continue + pkg = _package_of_file(repo, f) + cmd = _script_for(repo, pkg, f[len(pkg) + 1:] if pkg else f) if cmd: singles.append((pkg, cmd)) else: unrun.append(f) def cd(pkg, cmd): - return f"(cd {pkg} && {cmd})" if pkg else cmd - cmds = [cd(pkg, f"{c} " + " ".join(fs)) for (pkg, c), fs in by_runner.items()] + return f"(cd {shlex.quote(pkg)} && {cmd})" if pkg else cmd + cmds = [cd(pkg, f"{c} " + " ".join(shlex.quote(x) for x in fs)) for (pkg, c), fs in by_runner.items()] seen = set() for pkg, c in singles: if (pkg, c) not in seen: diff --git a/tests/README.md b/tests/README.md index 39445bbb1..58fe4e775 100644 --- a/tests/README.md +++ b/tests/README.md @@ -87,7 +87,9 @@ One check needs no graph and is its own script: plugin's dl/.cache binary is still used (#1606; needs soufflé and c++, no engine) python3 tests/test_command.py the command test-impact prints for TypeScript/JavaScript runs those files: each with the runner whose include globs collect it, else its package script, header or - package README, else named as collected by nothing (#1570; no engine) + package README, else named as collected by nothing; each runner's config read + as that runner reads it (vitest projects/exclude/vite.config, jest rootDir/ + testRegex/--config, playwright, node --test), each shape with a control (#1570; no engine) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/test_command.py b/tests/test_command.py index ff11292db..a87cab347 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -106,6 +106,121 @@ def tree(files): ti.command_for('typescript', ['src/a.test.ts'], [], d), 'npx vitest run src/a.test.ts') shutil.rmtree(d) +# a runner's collection is read the way the runner reads it. Each shape: (files, the file, expected plan) +V = {'devDependencies': {'vitest': '1'}} +J = {'scripts': {'test': 'jest'}, 'devDependencies': {'jest': '29'}} +for why, files, f, want in [ + ("vitest: coverage.include is not the test include", + {'package.json': V, 'vitest.config.mjs': "export default {test:{coverage:{include:['src/**']}}}"}, + 'test/a.test.ts', (['npx vitest run test/a.test.ts'], [])), + ("vitest: coverage.include beside a real include still leaves a file outside the include out (control)", + {'package.json': V, 'vitest.config.mjs': "export default {test:{include:['src/**/*.test.ts'],coverage:{include:['test/**']}}}"}, + 'test/a.test.ts', ([], ['test/a.test.ts'])), + ("vitest: a test block in vite.config.* is vitest's config", + {'package.json': V, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}"}, + 'tests/unit/a.ts', (['npx vitest run tests/unit/a.ts'], [])), + ("vitest: vite.config.* of a package that does not use vitest is not read (control)", + {'package.json': J, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}"}, + 'tests/unit/a.ts', ([], ['tests/unit/a.ts'])), + ("vitest: each inline project collects on its own", + {'package.json': V, 'vitest.config.mjs': + "export default {test:{projects:[{test:{include:['src/**/*.test.ts']}},{test:{include:['it/**/*.it.ts']}}]}}"}, + 'it/a.it.ts', (['npx vitest run it/a.it.ts'], [])), + ("vitest: a file no project includes is still left out (control)", + {'package.json': V, 'vitest.config.mjs': + "export default {test:{projects:[{test:{include:['src/**/*.test.ts']}},{test:{include:['it/**/*.it.ts']}}]}}"}, + 'e2e/a.it.ts', ([], ['e2e/a.it.ts'])), + ("vitest: a workspace member without its own config is collected with vitest's defaults", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/src/x.test.ts': ''}, + 'packages/a/src/x.test.ts', (['npx vitest run packages/a/src/x.test.ts'], [])), + ("vitest: a workspace member's own include is read from the member", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/vitest.config.ts': "export default {test:{include:['spec/**/*.ts']}}"}, + 'packages/a/spec/x.ts', (['(cd packages/a && npx vitest run spec/x.ts)'], [])), + ("vitest: exclude takes a file back out", + {'package.json': V, 'vitest.config.mjs': + "import {configDefaults} from 'vitest/config'\nexport default {test:{exclude:[...configDefaults.exclude,'e2e/**']}}"}, + 'e2e/a.spec.ts', ([], ['e2e/a.spec.ts'])), + ("vitest: a file outside the exclude is kept (control)", + {'package.json': V, 'vitest.config.mjs': + "import {configDefaults} from 'vitest/config'\nexport default {test:{exclude:[...configDefaults.exclude,'e2e/**']}}"}, + 'src/a.spec.ts', (['npx vitest run src/a.spec.ts'], [])), + ("vitest: an include this cannot read is not evidence the file is out", + {'package.json': V, 'vitest.config.ts': "import {inc} from './x'\nexport default {test:{include: inc}}"}, + 'weird/a.ts', (['npx vitest run weird/a.ts'], [])), + ("node:test through `tsx --test` in a glob script", + {'package.json': {'scripts': {'test:run': 'glob -c "tsx --test" "./test/**/*.ts"'}}}, + 'test/routes/api/users/users.test.ts', (['npx tsx --test test/routes/api/users/users.test.ts'], [])), + ("node:test through `node --test`", + {'package.json': {'scripts': {'test': 'node --test'}}}, 'test/a.test.js', (['node --test test/a.test.js'], [])), + ("playwright-only package: playwright runs its spec", + {'package.json': {'devDependencies': {'@playwright/test': '1'}}}, 'e2e/a.spec.ts', + (['npx playwright test e2e/a.spec.ts'], [])), + ("playwright testDir: a spec outside it is not playwright's", + {'package.json': {'devDependencies': {'@playwright/test': '1'}}, + 'playwright.config.ts': "export default defineConfig({ testDir: './e2e' })"}, 'src/a.spec.ts', + ([], ['src/a.spec.ts'])), + ("vitest excludes e2e and playwright takes it", + {'package.json': {'devDependencies': {'vitest': '1', '@playwright/test': '1'}}, + 'vitest.config.ts': "export default {test:{exclude:['e2e/**']}}", + 'playwright.config.ts': "export default { testDir: 'e2e' }"}, 'e2e/a.spec.ts', + (['npx playwright test e2e/a.spec.ts'], [])), + ("react-scripts runs its own jest", + {'package.json': {'scripts': {'test': 'react-scripts test'}, 'dependencies': {'react-scripts': '5'}}}, + 'src/App.test.js', (['npx react-scripts test --watchAll=false src/App.test.js'], [])), + ("a package with a package.json and no runner hands nothing to vitest", + {'package.json': {'name': 'x'}}, 'src/a.test.ts', ([], ['src/a.test.ts'])), + ("a jest config outranks vitest listed only as a dependency", + {'package.json': {'scripts': {'test': 'jest'}, 'devDependencies': {'jest': '29', 'vitest': '1'}}, + 'jest.config.js': "module.exports = {}"}, 'src/a.test.js', (['npx jest src/a.test.js'], [])), + ("a vitest config outranks jest listed only as a dependency (control)", + {'package.json': {'devDependencies': {'jest': '29', 'vitest': '1'}}, + 'vitest.config.ts': "export default {}"}, 'src/a.test.js', (['npx vitest run src/a.test.js'], [])), + ("jest testMatch with ", + {'package.json': J, 'jest.config.js': "module.exports = {testMatch:['/src/**/*.check.js']}"}, + 'src/a.check.js', (['npx jest src/a.check.js'], [])), + ("jest testRegex replaces testMatch", + {'package.json': J, 'jest.config.js': "module.exports = {testRegex:'\\\\.e2e-spec\\\\.js$'}"}, + 'test/app.e2e-spec.js', (['npx jest test/app.e2e-spec.js'], [])), + ("jest testRegex leaves a default-named file out (control)", + {'package.json': J, 'jest.config.js': "module.exports = {testRegex:'\\\\.e2e-spec\\\\.js$'}"}, + 'src/a.test.js', ([], ['src/a.test.js'])), + ("jest: package.json key with rootDir + a `jest --config` e2e config in a script", + {'package.json': {'scripts': {'test': 'jest', 'test:e2e': 'jest --config ./test/jest-e2e.json'}, + 'devDependencies': {'jest': '29'}, + 'jest': {'rootDir': 'src', 'testRegex': '.*\\.spec\\.ts$'}}, + 'test/jest-e2e.json': {'rootDir': '.', 'testRegex': '.e2e-spec.ts$'}}, + 'test/app.e2e-spec.ts', (['npx jest --config ./test/jest-e2e.json test/app.e2e-spec.ts'], [])), + ("jest: package.json rootDir src collects its spec (control)", + {'package.json': {'scripts': {'test': 'jest', 'test:e2e': 'jest --config ./test/jest-e2e.json'}, + 'devDependencies': {'jest': '29'}, + 'jest': {'rootDir': 'src', 'testRegex': '.*\\.spec\\.ts$'}}, + 'test/jest-e2e.json': {'rootDir': '.', 'testRegex': '.e2e-spec.ts$'}}, + 'src/app.service.spec.ts', (['npx jest src/app.service.spec.ts'], [])), + ("jest: a [jt] glob is read whole, and a nested e2e/jest.config.ts runs its own files", + {'package.json': J, 'jest.config.ts': "export default {testMatch:['/src/**/*(*.)@(spec|test).[jt]s?(x)']}", + 'e2e/jest.config.ts': "export default {testMatch:['/src/**/*.spec.ts']}"}, + 'e2e/src/server/server.spec.ts', (['(cd e2e && npx jest src/server/server.spec.ts)'], [])), + ("jest: the root [jt] glob still collects its own src spec (control)", + {'package.json': J, 'jest.config.ts': "export default {testMatch:['/src/**/*(*.)@(spec|test).[jt]s?(x)']}", + 'e2e/jest.config.ts': "export default {testMatch:['/src/**/*.spec.ts']}"}, + 'src/app/a.spec.tsx', (['npx jest src/app/a.spec.tsx'], [])), + ("a script that only lints the file is not its test command", + {'package.json': {'scripts': {'lint': 'eslint e2e/run-e2e.ts'}, 'devDependencies': {'vitest': '1'}}}, + 'e2e/run-e2e.ts', ([], ['e2e/run-e2e.ts'])), + ("a test script whose own glob takes the file runs it", + {'package.json': {'scripts': {'test': 'cross-env NODE_ENV=test babel-tape-runner test/test-*.js'}}}, + 'test/test-users.js', (['npm run test'], [])), + ("a lint script's glob is not a test command (control)", + {'package.json': {'scripts': {'lint': 'eslint "test/**/*.js"'}}}, 'test/test-users.js', ([], ['test/test-users.js'])), + ("a package directory with a space is quoted", + {'my pkg/package.json': V}, 'my pkg/src/a.test.ts', (["(cd 'my pkg' && npx vitest run src/a.test.ts)"], [])), +]: + d = tree(files) + check(why, ti.js_plan(d, [f]), want) + shutil.rmtree(d) + # control: other languages are untouched check("python unchanged (control)", ti.command_for('python', ['tests/test_a.py'], []), 'pytest tests/test_a.py') check("java unchanged (control)", ti.command_for('java', [], ['app.ATest']), 'mvn test -Dtest=ATest') From a60dfcc387e18299277b6fa0cc79343306a25dbc Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:30:49 -0700 Subject: [PATCH 23/37] test-impact: the hook's body-edit command and the per-package TS/JS command share one signature command_for takes the graph (for Java/C# concrete test classes) and the repository (for each TS/JS file's package and runner): command_for(lang, files, classes, db=None, repo='.'). The edit hooks pass the repository they located, so a TypeScript body edit gets the command its package runs, and a multi-package plan stays on the hook's one line. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/_graphline.py | 12 +++++++----- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/enrich.py | 2 +- tests/test_command.py | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index ea164afe2..abff45b86 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -102,10 +102,12 @@ def _ti(): return _TI or None -def _command_for(lang, files, classes, db=None): - """the runnable command `axiomcode test-impact` prints, from the same function""" - try: return _ti().command_for(lang, files, classes, db) +def _command_for(lang, files, classes, db=None, repo='.'): + """the runnable command `axiomcode test-impact` prints, from the same function; a TypeScript/JavaScript selection + can need one command per package and runner, joined with '; ' here because the hook's answer is one line""" + try: cmd = _ti().command_for(lang, files, classes, db, repo) except Exception: return None + return cmd.replace("\n", "; ") if cmd else cmd def _concrete(db, classes): @@ -119,7 +121,7 @@ def _concrete(db, classes): SHOWN = 6 -def body_line(db, results): +def body_line(db, results, repo='.'): """ONE line for an edit that changed only bodies: which declarations, how many tests reach them, how to run those. `results` is [(changed-declaration, impact-json)], the same pairs the blast-radius block is built from.""" names = [d['symbol'] for d, _ in results] @@ -149,7 +151,7 @@ def body_line(db, results): fl, cl = sorted(files), sorted(owners) if lang in ('java', 'csharp') and cl: cl = sorted(_concrete(db, cl)) # before the cut, so the command and the "+N more" count the same classes - cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN]) + cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN], None, repo) more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command" return f"graph: body edit of {what}: {n} test(s) reach it{tail}" diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index f8858f388..ef48771ab 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -118,7 +118,7 @@ def impact(d): if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") if bodies: lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), - bodies + [(d, {}) for d in body[3:]])) + bodies + [(d, {}) for d in body[3:]], cwd)) return lines def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 6d1dbc198..002b1d180 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -231,7 +231,7 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") if bodies: lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), - bodies + [(d, {}) for d in body[3:]])) + bodies + [(d, {}) for d in body[3:]], cwd)) elif tool == 'Read': fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) a = int(inp.get('offset') or 1); b = a + int(inp.get('limit') or 100000) diff --git a/tests/test_command.py b/tests/test_command.py index a87cab347..f8081584b 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -103,7 +103,7 @@ def tree(files): # control: nothing configured anywhere, a *.test.ts file still gets the command it always got d = tree({'src/a.test.ts': ''}) check("nothing configured: a *.test.ts file keeps `npx vitest run` (control)", - ti.command_for('typescript', ['src/a.test.ts'], [], d), 'npx vitest run src/a.test.ts') + ti.command_for('typescript', ['src/a.test.ts'], [], repo=d), 'npx vitest run src/a.test.ts') shutil.rmtree(d) # a runner's collection is read the way the runner reads it. Each shape: (files, the file, expected plan) From 66379700907c1830abcc6a36716dee46e7a3df3c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:25:00 -0700 Subject: [PATCH 24/37] test-impact: read a TS/JS runner config as the value it exports (#1570) A config was read as the literal keys it spells, so an include behind a const, a shorthand property, a spread of configDefaults, a re-export or a mergeConfig base was taken as the runner's defaults, and a file the runner collects was named as collected by nothing. The config is now evaluated: defineConfig and config functions, consts, shorthand, spreads of the runners' defaults, mergeConfig, re-exports and relative imports. What it still cannot read is unknown, and unknown never excludes a file. Also: vitest test.dir, workspace members configured by vite.config.*, `vitest --config x` scripts, jest `!glob` in testMatch, and mocha --extension/--recursive run with the script's flags. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-test-impact | 1047 ++++++++++++++--- tests/test_command.py | 88 ++ 2 files changed, 942 insertions(+), 193 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index d853edc02..388fdab54 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -165,6 +165,12 @@ def command_for(lang, files, classes, db=None, repo='.'): # testMatch is relative to , testRegex replaces it, and `jest --config x` in a script is a config too. # The nearest directory whose runner collects the file is where the command runs from, so a nested # e2e/jest.config.ts runs its own files. A runner with a config outranks one that is only a dependency. +# +# A config is read as the value the file exports (_js_config), so the include is found wherever the file puts it: +# behind defineConfig or a config function, in a const or a shorthand property, spread from the runner's own +# defaults, merged by mergeConfig, re-exported or imported from a file beside it. A value it still cannot read +# (computed at load time, from a package it does not know) is unknown, and unknown is never taken as the +# runner's defaults: an unknown include keeps the command, an unknown exclude takes nothing out. _CONF_EXT = ('ts', 'mts', 'cts', 'js', 'mjs', 'cjs') _RUNNERS = ( # runner, config files, package.json key, command @@ -176,9 +182,9 @@ _RUNNERS = ( ('playwright', tuple(f'playwright.config.{e}' for e in _CONF_EXT), None, 'npx playwright test'), ) _VITEST_INCLUDE = ('**/*.{test,spec}.?(c|m)[jt]s?(x)',) -_VITEST_EXCLUDE = ('**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**') +_VITEST_EXCLUDE = ('**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*') _JEST_MATCH = ('**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)') -_MOCHA_SPEC = ('test/*.{js,cjs,mjs}',) _PW_MATCH = ('**/*.@(spec|test).?(c|m)[jt]s?(x)',) _NODE_TEST = ('**/*.test.?(c|m)[jt]s', '**/*-test.?(c|m)[jt]s', '**/*_test.?(c|m)[jt]s', '**/test-*.?(c|m)[jt]s', '**/test.?(c|m)[jt]s', '**/test/**/*.?(c|m)[jt]s') @@ -196,67 +202,555 @@ def _unq(s): return re.sub(r'\\(.)', lambda m: {'n': '\n', 't': '\t'}.get(m.group(1), m.group(1)), s[1:-1]) -def _js_keys(text, keys): - """Every `key: value` in a JS or JSON config whose key is in `keys`, as (path, key, value, owner, parents). +class _Unknown: + """A config value this cannot read: a call it does not know, an env lookup, a file it cannot load.""" + def __repr__(self): return '?' + + +_UNK = _Unknown() +_PARTIAL = '\0partial' # an object with a spread this cannot read: any key it does not spell is unknown + + +class _Fn: + """A function value: what it returns (a runner calls a config function and reads its result).""" + def __init__(self, ret): self.ret = ret - path is the chain of keys enclosing it, owner the id of the object it sits in, parents each object's - enclosing object. value is a list of the string (or regex, as ('re', src)) literals, or None when the value - is not a literal this can read — a reader must then assume the file is in, not out.""" - toks, i, n = [], 0, len(text) + +_VITEST_MOD = {'configDefaults': {'include': list(_VITEST_INCLUDE), 'exclude': list(_VITEST_EXCLUDE)}, + 'defaultInclude': list(_VITEST_INCLUDE), 'defaultExclude': list(_VITEST_EXCLUDE)} +_JEST_DEFAULTS = {'testMatch': list(_JEST_MATCH), 'testRegex': [], 'testPathIgnorePatterns': ['/node_modules/']} +_KNOWN_MODULES = {'vitest/config': _VITEST_MOD, 'vitest/node': _VITEST_MOD, 'vitest': _VITEST_MOD, + 'jest-config': {'defaults': _JEST_DEFAULTS}} +_IDENTITY = {'defineConfig', 'defineProject', 'defineWorkspace', 'defineVitestConfig', 'defineVitestProject'} +_OPS = set('|&?:+-*%<>=!^~') + + +def _js_tokens(text): + """(kind, text, newline-before) for each token: `s` string, `re` regex, `id` word or number, `p` punctuation.""" + toks, i, n, nl = [], 0, len(text), False while i < n: prev = toks[-1][1] if toks else '' - if text[i] == '/' and prev in (':', '[', ',', '(', '=') and not text.startswith(('//', '/*'), i): + if text[i] == '/' and (prev in ('', ':', '[', ',', '(', '=', '!', '&', '|', '?', '{', ';', 'return') + ) and not text.startswith(('//', '/*'), i): m = _JS_RE.match(text, i) if m: - toks.append(('re', m.group(1))); i = m.end(); continue + toks.append(('re', m.group(1), nl)); i = m.end(); nl = False; continue m = _JS_TOK.match(text, i) - kind = m.lastgroup - if kind != 'ws': - toks.append((kind, m.group(0))) + if m.lastgroup != 'ws': + toks.append((m.lastgroup, m.group(0), nl)); nl = False + elif '\n' in m.group(0): + nl = True i = m.end() - out, stack, parents, nid, j = [], [], {}, 0, 0 - collecting = None - while j < len(toks): - kind, v = toks[j] - if kind in ('id', 's') and j + 1 < len(toks) and toks[j + 1][1] == ':' and stack and stack[-1][0] == '{': - key = _unq(v) if kind == 's' else v - owner = stack[-1][2] - path = tuple(k for _, k, _ in stack if k) - j += 2 - nxt = toks[j] if j < len(toks) else ('', '') - if key in keys: - if nxt[0] == 's': - out.append((path, key, [_unq(nxt[1])], owner, parents)) - elif nxt[0] == 're': - out.append((path, key, [('re', nxt[1])], owner, parents)) - elif nxt[1] == '[': - collecting = (len(stack) + 1, []) - out.append((path, key, collecting[1], owner, parents)) + return toks + + +class _PkgMod(dict): + """A package's exports: the values this knows (vitest's configDefaults), else the export's name, so a call to + it (defineConfig, mergeConfig) is still read by name.""" + + +def _get(obj, key): + """obj[key] the way a runner reads its options: None when absent, _UNK when it cannot be known.""" + if not isinstance(obj, dict): return None if obj is None else _UNK + if key in obj: return obj[key] + if isinstance(obj, _PkgMod): return ('global', key) + return _UNK if obj.get(_PARTIAL) else None + + +def _merge(a, b): + """vite's mergeConfig: objects merge key by key, arrays concatenate, anything else is replaced.""" + if a is _UNK or b is _UNK: return _UNK + if isinstance(a, dict) and isinstance(b, dict): + out = dict(a) + for k, v in b.items(): + out[k] = _merge(out[k], v) if k in out and k != _PARTIAL else v + return out + if isinstance(a, list) or isinstance(b, list): + return (a if isinstance(a, list) else [] if a is None else [a]) + (b if isinstance(b, list) else [b]) + return a if b is None else b + + +def _resolve_module(base_dir, spec): + for cand in (spec, *(spec + '.' + e for e in _CONF_EXT + ('json',)), + *(posixpath.join(spec, 'index.' + e) for e in _CONF_EXT)): + p = os.path.join(base_dir, cand) + if os.path.isfile(p): return p + # `./x.js` written for a file that is `./x.ts` (TypeScript's own import spelling) + stem, ext = os.path.splitext(spec) + if ext in ('.js', '.mjs', '.cjs'): + for e in ('ts', 'mts', 'cts'): + p = os.path.join(base_dir, f'{stem}.{e}') + if os.path.isfile(p): return p + return None + + +_LOADING = set() + + +@functools.lru_cache(maxsize=None) +def _js_module(path): + """A config file's exports as {'default': value, name: value}; each value a Python dict/list/str, a + ('re', src) regex, a _Fn, or _UNK where the file does not say it literally (or through a const, a spread, a + shorthand, a re-export, an import of a file beside it, defineConfig or mergeConfig).""" + if path in _LOADING or len(_LOADING) > 6: return {'default': _UNK} + _LOADING.add(path) + try: + text = _read(path) + toks = _js_tokens(text) + if path.endswith(('.json', '.jsonc')): + try: + return {'default': json.loads(text)} + except ValueError: # a .json with comments (jsonc) is read as the JS expression it is + toks = [('id', 'export', False), ('id', 'default', False)] + toks + return _JsEval(path, toks).exports + except Exception: + return {'default': _UNK} + finally: + _LOADING.discard(path) + + +def _js_config(path): + """The value a config file exports, a config function's result taken for the function.""" + v = _js_module(path).get('default', _UNK) + return v.ret if isinstance(v, _Fn) else v + + +class _JsEval: + """Reads the module-level JS a config file is written in, enough to know what it exports. Not a JS engine: + any expression it does not know evaluates to _UNK, and parsing never fails, only gives up on a value.""" + CLOSE = {')', ']', '}', ',', ';'} + + def __init__(self, path, toks): + self.dir, self.t, self.exports = os.path.dirname(path), toks, {} + env = {} + self.block(0, len(toks), env, None) + if 'default' not in self.exports: + self.exports['default'] = _UNK + + def v(self, i): + return self.t[i][1] if i < len(self.t) else '' + + def kind(self, i): + return self.t[i][0] if i < len(self.t) else '' + + def nl(self, i): + return self.t[i][2] if i < len(self.t) else True + + def skip_balanced(self, i): + """Past the bracket at i and everything up to its match.""" + depth = 0 + while i < len(self.t): + x = self.v(i) + if self.kind(i) == 'p' and x in '([{': depth += 1 + elif self.kind(i) == 'p' and x in ')]}': + depth -= 1 + if depth <= 0: return i + 1 + i += 1 + return i + + def skip_type(self, i, stop): + """Past a TypeScript type, up to a token in `stop` at depth 0, or a line break that ends the statement.""" + depth, start = 0, i + while i < len(self.t): + x = self.v(i) + if depth == 0 and x in stop and self.kind(i) == 'p': return i + if depth == 0 and i > start and self.nl(i) and self.v(i - 1) not in ('|', '&', ':', '=', ',', '<', '>') \ + and x not in ('|', '&', '.', '[', '<', '{', '=', ':'): + return i + if x in '([{<' and self.kind(i) == 'p': depth += 1 + elif x in ')]}>' and self.kind(i) == 'p': + if x == '>' and self.v(i - 1) == '=': pass # `=>` in a function type + else: depth -= 1 + i += 1 + return i + + def module(self, spec): + if spec in _KNOWN_MODULES: return {'default': _PkgMod(_KNOWN_MODULES[spec]), **_KNOWN_MODULES[spec]} + if spec.startswith('.'): + p = _resolve_module(self.dir, spec) + if p: return _js_module(p) + return {} + + def imported(self, spec, name): + mod = self.module(spec) + if not spec.startswith('.'): # a package: its known values, else the export by name + mod = _PkgMod(mod) + return mod if name == '*' else _get(mod, name) + if name == '*': return mod or _UNK + return mod.get(name, _UNK) + + # ---- statements --------------------------------------------------------------------------------------------- + def block(self, i, end, env, rets): + """Statements from i to end. `rets` collects a function body's return values (None at module level).""" + while i < end: + x, k = self.v(i), self.kind(i) + if k == 'p' and x in (';', ',', '}', ')', ']'): + i += 1; continue + if k == 'id' and x == 'import' and self.v(i + 1) not in ('(', '.'): + i = self.stmt_import(i + 1, env); continue + if k == 'id' and x == 'export': + i = self.stmt_export(i + 1, env, rets); continue + if k == 'id' and x in ('const', 'let', 'var'): + i = self.stmt_decl(i + 1, env); continue + if k == 'id' and x in ('function', 'async') and (x == 'function' or self.v(i + 1) == 'function'): + j = i + 1 if x == 'function' else i + 2 + if self.v(j) == '*': j += 1 + name = self.v(j) if self.kind(j) == 'id' else None + fn, i = self.function(j + 1 if name else j, env) + if name: env[name] = fn + continue + if k == 'id' and x in ('type', 'interface', 'declare', 'enum', 'namespace') and self.kind(i + 1) == 'id': + j = self.skip_type(i + 2, {'=', '{', ';'}) + if self.v(j) == '=': + i = self.skip_type(j + 1, {';'}); continue + i = self.skip_balanced(j) if self.v(j) == '{' else j + 1; continue + if k == 'id' and x == 'return' and rets is not None: + val, i = self.expr(i + 1, env) + rets.append(val); continue + if k == 'id' and x in ('if', 'for', 'while', 'switch', 'with', 'catch'): + i = self.skip_balanced(i + 1) if self.v(i + 1) == '(' else i + 1; continue + if k == 'id' and x in ('else', 'try', 'finally', 'do'): + i += 1; continue + if k == 'p' and x == '{': + j = self.skip_balanced(i) + sub = [] if rets is not None else None + self.block(i + 1, j - 1, dict(env), sub) + if rets is not None: rets.extend(sub + ([_UNK] if sub else [])) # a return under a condition + i = j; continue + if k == 'id' and x == 'module' and self.v(i + 1) == '.' and self.v(i + 2) == 'exports': + if self.v(i + 3) == '=': + self.exports['default'], i = self.expr(i + 4, env); continue + if self.v(i + 3) == '.' and self.v(i + 5) == '=': + val, i = self.expr(i + 6, env) + d = self.exports.get('default') + if isinstance(d, dict): d[self.v(i + 4)] = val + continue + if k == 'id' and x == 'exports' and self.v(i + 1) == '.' and self.v(i + 3) == '=': + name = self.v(i + 2) + self.exports[name], i = self.expr(i + 4, env); continue + _, j = self.expr(i, env) + i = j if j > i else i + 1 + return i + + def stmt_import(self, i, env): + if self.kind(i) == 's': + return i + 1 # import 'side-effect' + if self.v(i) == 'type': i += 1 + names = [] # (local, imported) + while i < len(self.t) and self.v(i) != 'from': + x = self.v(i) + if x == '*' and self.v(i + 1) == 'as': + names.append((self.v(i + 2), '*')); i += 3; continue + if x == '{': + j = i + 1 + while j < len(self.t) and self.v(j) != '}': + if self.kind(j) in ('id', 's') and self.v(j) != 'type': + imp = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + if self.v(j + 1) == 'as': + names.append((self.v(j + 2), imp)); j += 3 + else: + names.append((imp, imp)); j += 1 + continue + j += 1 + i = j + 1; continue + if self.kind(i) == 'id': + names.append((x, 'default')) + i += 1 + spec = _unq(self.v(i + 1)) if self.kind(i + 1) == 's' else '' + for local, imp in names: + env[local] = self.imported(spec, imp) if spec else _UNK + return i + 2 + + def stmt_export(self, i, env, rets): + x = self.v(i) + if x == 'default': + self.exports['default'], j = self.expr(i + 1, env) + return j + if x in ('const', 'let', 'var'): + before = dict(env) + j = self.stmt_decl(i + 1, env) + for name, val in env.items(): + if before.get(name, None) is not val: self.exports[name] = val + return j + if x in ('function', 'async'): + j = i + 1 if x == 'function' else i + 2 + name = self.v(j) if self.kind(j) == 'id' else None + fn, j = self.function(j + 1 if name else j, env) + if name: env[name] = self.exports[name] = fn + return j + if x == 'type' and self.v(i + 1) == '{' or x == '{' or x == '*': + if x == 'type': i += 1 + pairs, j = [], i + 1 + if x == '*': + j = i + 1 + if self.v(j) == 'as': pairs.append((self.v(j + 1), '*')); j += 2 + else: + while j < len(self.t) and self.v(j) != '}': + if self.kind(j) in ('id', 's'): + src = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + if self.v(j + 1) == 'as': + pairs.append((self.v(j + 2), src)); j += 3 + else: + pairs.append((src, src)); j += 1 + continue + j += 1 + j += 1 + if self.v(j) == 'from' and self.kind(j + 1) == 's': + spec = _unq(self.v(j + 1)) + for out, src in pairs: + self.exports[out] = self.imported(spec, src) + return j + 2 + for out, src in pairs: + self.exports[out] = env.get(src, _UNK) + return j + return i + + def stmt_decl(self, i, env): + while i < len(self.t): + if self.kind(i) == 'id': + name, i = self.v(i), i + 1 + if self.v(i) == '!': i += 1 + if self.v(i) == ':': i = self.skip_type(i + 1, {'=', ',', ';'}) + if self.v(i) == '=': + env[name], i = self.expr(i + 1, env) else: - out.append((path, key, None, owner, parents)) - if nxt[1] in '{[(' and nxt[1]: - stack.append((nxt[1], key, None)); j += 1 - if nxt[1] == '{': - nid += 1; parents[nid] = next((o for b, _, o in reversed(stack[:-1]) if b == '{'), None) - stack[-1] = ('{', key, nid) - if key in keys: out[-1] = (path, key, ('obj', nid), owner, parents) - continue - if kind == 's' and stack and stack[-1][0] == '[' and all(b != '{' for b, _, _ in stack): - out.append(((), '', [_unq(v)], None, parents)) # a bare list entry: a workspace member - if v in ('{', '[', '('): - oid = None - if v == '{': - nid += 1; oid = nid; parents[nid] = next((o for b, _, o in reversed(stack) if b == '{'), None) - stack.append((v, None, oid)) - elif v in ('}', ']', ')'): - if collecting and len(stack) == collecting[0]: - collecting = None - if stack: stack.pop() - elif collecting and len(stack) == collecting[0]: - if kind == 's': collecting[1].append(_unq(v)) - elif kind == 're': collecting[1].append(('re', v)) - j += 1 - return out + env[name] = None + elif self.v(i) in ('{', '['): + pat_end = self.skip_balanced(i) + j = pat_end + if self.v(j) == ':': j = self.skip_type(j + 1, {'=', ',', ';'}) + val = _UNK + if self.v(j) == '=': + val, j = self.expr(j + 1, env) + self.destructure(i, pat_end, val, env) + i = j + else: + return i + if self.v(i) != ',': return i + i += 1 + return i + + def destructure(self, i, end, val, env): + """`{ a, b: c, ...rest }` / `[a, b]` bound from val (a name it cannot bind is _UNK).""" + if self.v(i) == '[': + names = [self.v(j) for j in range(i + 1, end - 1) if self.kind(j) == 'id'] + for n, name in enumerate(names): + env[name] = val[n] if isinstance(val, list) and n < len(val) else _UNK + return + j = i + 1 + while j < end - 1: + if self.v(j) == '...' and self.kind(j + 1) == 'id': + env[self.v(j + 1)] = _UNK; j += 2; continue + if self.kind(j) in ('id', 's'): + key = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + local = key + if self.v(j + 1) == ':' and self.kind(j + 2) == 'id': + local = self.v(j + 2); j += 2 + env[local] = _get(val, key) + j += 1 + if self.v(j) == '=': # a default: skip it + while j < end - 1 and self.v(j) != ',': j = self.skip_balanced(j) if self.v(j) in '([{' else j + 1 + continue + j += 1 + + # ---- expressions -------------------------------------------------------------------------------------------- + def function(self, i, env): + """A function from its parameter list at i (a `name` already consumed): (_Fn, index after it).""" + if self.v(i) == '<': i = self.skip_type(i + 1, {'>'}) + 1 # generics + if self.v(i) == '(': + i = self.skip_balanced(i) + elif self.kind(i) == 'id': + i += 1 + if self.v(i) == ':': i = self.skip_type(i + 1, {'{', '='}) + if self.v(i) == '=' and self.v(i + 1) == '>': i += 2 + if self.v(i) == '{': + j = self.skip_balanced(i) + rets = [] + self.block(i + 1, j - 1, dict(env), rets) + return _Fn(rets[0] if len(rets) == 1 else _UNK), j + val, j = self.expr(i, env) + return _Fn(val), j + + def arrow_at(self, i): + """Whether an arrow function's parameters start at i (`(a, b) =>`, `x =>`, `(x): T =>`).""" + if self.kind(i) == 'id' and self.v(i + 1) == '=' and self.v(i + 2) == '>': return True + if self.v(i) != '(': return False + j = self.skip_balanced(i) + if self.v(j) == ':': j = self.skip_type(j + 1, {'='}) + return self.v(j) == '=' and self.v(j + 1) == '>' + + def expr(self, i, env): + val, i = self.unary(i, env) + while i < len(self.t) and self.kind(i) == 'p' and self.v(i) in _OPS: + if self.v(i) == '=' and self.v(i + 1) == '>': break + if self.v(i) == '?' and self.v(i + 1) == '.': break + while self.kind(i) == 'p' and self.v(i) in _OPS and not (self.v(i) == '-' and self.v(i + 1) == '-'): + i += 1 + if self.v(i) in self.CLOSE: break + rhs, i = self.unary(i, env) + val = _UNK + while self.kind(i) == 'id' and self.v(i) in ('as', 'satisfies') and not self.nl(i): + i = self.skip_type(i + 1, {',', ';', ')', ']', '}'}) + return val, i + + def unary(self, i, env): + x = self.v(i) + if x in ('await', 'typeof', 'void', 'delete', 'new') and self.kind(i) == 'id': + val, j = self.unary(i + 1, env) + return (val if x == 'await' else _UNK), j + if self.kind(i) == 'p' and x in ('!', '-', '+', '~', '...'): + _, j = self.unary(i + 1, env) + return _UNK, j + val, i = self.primary(i, env) + return self.postfix(val, i, env) + + def postfix(self, val, i, env): + while i < len(self.t): + x = self.v(i) + if x == '.' or (x == '?' and self.v(i + 1) == '.'): + i += 2 if x == '?' else 1 + if self.v(i) in ('(', '['): continue # `?.(` / `?.[` + name, i = self.v(i), i + 1 + # a member of a name this does not know is kept by name (`vite.mergeConfig`), and read as unknown + val = ('global', name) if isinstance(val, tuple) and val[0] == 'global' else \ + (_get(val, name) if isinstance(val, dict) else _UNK) + continue + if x == '!' and self.v(i + 1) != '=': + i += 1; continue + if x == '[' and not self.nl(i): + key, j = self.expr(i + 1, env) + i = self.skip_balanced(i) + val = (_get(val, key) if isinstance(val, dict) and isinstance(key, str) else + val[int(key)] if isinstance(val, list) and isinstance(key, str) and key.isdigit() + and int(key) < len(val) else _UNK) + continue + if x == '<' and isinstance(val, tuple) and val[0] == 'global': + j = self.skip_type(i + 1, {'>'}) + if self.v(j) == '>' and self.v(j + 1) == '(': + i = j + 1; continue + return val, i + if x == '(' and not self.nl(i): + args, j = [], i + 1 + while j < len(self.t) and self.v(j) != ')': + a, k = self.expr(j, env) + args.append(a) + j = k if k > j else j + 1 + if self.v(j) == ',': j += 1 + i = j + 1 + val = self.call(val, args) + continue + if self.kind(i) == 's' and self.v(i).startswith('`'): # a tagged template + i += 1; val = _UNK; continue + break + return (None if isinstance(val, tuple) and val[0] == 'global' and val[1] == 'undefined' + else _UNK if isinstance(val, tuple) and val[0] == 'global' else val), i + + def call(self, fn, args): + name = fn[1] if isinstance(fn, tuple) and fn[0] == 'global' else None + if name in _IDENTITY: + a = args[0] if args else _UNK + return a.ret if isinstance(a, _Fn) else a + if name == 'mergeConfig': + out = None + for a in args[:2]: + a = a.ret if isinstance(a, _Fn) else a + out = a if out is None else _merge(out, a) + return _UNK if out is None else out + if name == 'require' and args and isinstance(args[0], str): + return self.imported(args[0], 'default') if args[0].startswith('.') else self.imported(args[0], '*') + if isinstance(fn, _Fn): + return fn.ret + return _UNK + + def primary(self, i, env): + x, k = self.v(i), self.kind(i) + if k == 's': + if x.startswith('`') and '${' in x: return _UNK, i + 1 + return _unq(x), i + 1 + if k == 're': + return ('re', x), i + 1 + if k == 'p' and x == '{': + return self.object(i, env) + if k == 'p' and x == '[': + return self.array(i, env) + if k == 'id' and x == 'async' and (self.arrow_at(i + 1) or self.v(i + 1) == 'function'): + return self.primary(i + 1, env) + if self.arrow_at(i): + return self.function(i, env) + if k == 'p' and x == '(': + val, j = self.expr(i + 1, env) + return val, self.skip_balanced(i) + if k == 'id' and x == 'function': + j = i + 1 + if self.v(j) == '*': j += 1 + if self.kind(j) == 'id' and self.v(j + 1) in ('(', '<'): j += 1 + return self.function(j, env) + if k == 'id' and x == 'class': + j = i + 1 + while j < len(self.t) and self.v(j) != '{': j += 1 + return _UNK, self.skip_balanced(j) + if k == 'id': + if x in ('true', 'false'): return x == 'true', i + 1 + if x == 'null': return None, i + 1 + if x[0].isdigit(): return x, i + 1 + if x == 'import' and self.v(i + 1) in ('(', '.'): + return ('global', 'import()'), i + 1 + if x in env: return env[x], i + 1 + return ('global', x), i + 1 + return _UNK, i # nothing here this can read: do not advance + + def object(self, i, env): + out, j = {}, i + 1 + while j < len(self.t) and self.v(j) != '}': + x, k = self.v(j), self.kind(j) + if x == ',': + j += 1; continue + if x == '...': + val, j = self.expr(j + 1, env) + val = val.ret if isinstance(val, _Fn) else val + if isinstance(val, dict): out.update(val) + else: out[_PARTIAL] = True + continue + if x == '[': # a computed key: its entry cannot be named + j = self.skip_balanced(j) + out[_PARTIAL] = True + if self.v(j) == ':': _, j = self.expr(j + 1, env) + elif self.v(j) == '(': _, j = self.function(j, env) + continue + if k in ('id', 's'): + key = _unq(x) if k == 's' else x + if k == 'id' and x in ('get', 'set', 'async') and self.kind(j + 1) in ('id', 's') \ + and self.v(j + 2) == '(': + j += 1; key = self.v(j) + if self.v(j + 1) == '?': j += 1 + nxt = self.v(j + 1) + if nxt == ':': + out[key], j = self.expr(j + 2, env) + elif nxt in ('(', '<'): + fn, j = self.function(j + 1, env); out[key] = fn + elif nxt == '=': # a destructuring default, not an object literal + out[key], j = _UNK, j + 2 + _, j = self.expr(j, env) + else: + out[key] = env.get(key, _UNK); j += 1 # shorthand `{ test }` + continue + _, nj = self.expr(j, env) + j = nj if nj > j else j + 1 + return out, j + 1 + + def array(self, i, env): + out, j = [], i + 1 + while j < len(self.t) and self.v(j) != ']': + if self.v(j) == ',': + j += 1; continue + if self.v(j) == '...': + val, j = self.expr(j + 1, env) + out.extend(val if isinstance(val, list) else [_UNK]) + continue + val, nj = self.expr(j, env) + out.append(val) + j = nj if nj > j else j + 1 + return out, j + 1 def _glob_re(g): @@ -333,11 +827,6 @@ def _under(rel, base): return None if r.startswith('../') or r == '..' else r -def _lit(v): - """A key's literal value as a list: [] when absent, None when it is there but not a literal.""" - return v if v is None or isinstance(v, list) else None - - def _pats_match(pats, rel, glob_rel=None): """Whether any glob (matched on glob_rel) or regex (searched in '/'+rel) of `pats` takes this file.""" for p in pats: @@ -351,114 +840,233 @@ def _pats_match(pats, rel, glob_rel=None): return False +def _pat_list(v): + """A pattern option as a list (a lone string or regex is one pattern): None when unset, _UNK when unreadable.""" + if v is None or v is _UNK or isinstance(v, list): return v + if isinstance(v, str) or isinstance(v, tuple) and v[:1] == ('re',): return [v] + return _UNK + + +def _pats_take(pats, rel, unknown, glob_rel=None): + """Whether a pattern list takes rel — or `unknown` when it does not say (a pattern this could not read).""" + if pats is _UNK: return unknown + unread = False + for p in pats: + if isinstance(p, str) or isinstance(p, tuple) and p[:1] == ('re',): + if _pats_match([p], rel, glob_rel): return True + else: + unread = True + return unknown if unread else False + + def _member_dirs(entries, rel): - """The directories on rel's path that a list of workspace / project globs names.""" + """The directories on rel's path that a list of workspace / project globs names (a `!glob` takes one out).""" parts, out = rel.split('/')[:-1], [] + neg = [e[1:] for e in entries if isinstance(e, str) and e.startswith('!')] for e in entries: - if not isinstance(e, str): continue + if not isinstance(e, str) or e.startswith('!'): continue e = e.replace('/', '').rstrip('/') e = e[2:] if e.startswith('./') else e - if re.search(r'\.(c|m)?[jt]s$|\.json$', e): e = posixpath.dirname(e) - for k in range(1, len(parts) + 1): - pre = '/'.join(parts[:k]) - if _globs_match((e,), pre): out.append(pre) + if re.search(r'\.(c|m)?[jt]s$|\.json$', e): e = posixpath.dirname(e) or '.' + for k in range(0, len(parts) + 1): + pre = '/'.join(parts[:k]) or '.' + if _globs_match((e,), pre) and not any(_globs_match((n.rstrip('/'),), pre) for n in neg) \ + and pre not in out: + out.append(pre) return out -def _vitest_collects(texts, repo, d): - """How a vitest config (and workspace file) collects: a function of the path from its directory.""" - projects = [] # (root, include, exclude) — one per test block, each collecting on its own - members = [] - for t in texts: - recs = _js_keys(t, {'test', 'include', 'exclude', 'root', 'projects', 'workspace'}) - tests = [r[2][1] for r in recs if r[1] == 'test' and isinstance(r[2], tuple)] - for tid in tests: - own = {r[1]: r[2] for r in recs if r[3] == tid} - if 'projects' in own or 'workspace' in own: - members += [e for k in ('projects', 'workspace') for e in (own.get(k) or [])] - continue - root = own.get('root') or next((r[2] for r in recs if r[1] == 'root' and r[3] == r[4].get(tid)), None) - projects.append((root[0] if isinstance(root, list) and root else '.', - own.get('include', _VITEST_INCLUDE), own.get('exclude') or [])) - members += [e for r in recs if r[1] == '' for e in r[2]] - if not projects and not members: - projects.append(('.', _VITEST_INCLUDE, [])) - - def collects(rel): - for root, inc, exc in projects: - r = _under(rel, root) - if r is None: continue - if _pats_match(_VITEST_EXCLUDE, r) or _pats_match(exc, r): continue - # an include this cannot read (a variable, only spreads) is not evidence the file is left out - if not isinstance(inc, (list, tuple)) or not inc or _pats_match(inc, r): return True - for m in _member_dirs(members, rel): - # a member with a vitest config of its own is read from there; one without collects vitest's defaults - if any(os.path.isfile(os.path.join(repo, d, m, c)) for c in _RUNNERS[0][1]): continue - if _pats_match(_VITEST_INCLUDE, _under(rel, m)): return True - return False - return collects - +_VITEST_CONFS = tuple(f'vitest.config.{e}' for e in _CONF_EXT) +_VITE_CONFS = tuple(f'vite.config.{e}' for e in _CONF_EXT) +_VITEST_WS = tuple(f'vitest.workspace.{e}' for e in _CONF_EXT) + ('vitest.workspace.json',) -def _jest_collects(text, base, repo, d): - """How a jest config at `base` (from the runner's directory `d`) collects.""" - keys = {'testMatch', 'testRegex', 'rootDir', 'roots', 'testPathIgnorePatterns', 'projects'} - recs = _js_keys(text, keys) - top = [r for r in recs if r[1] == 'projects'] - owners = collections.OrderedDict() - for r in recs: - if r[1] in keys and r[1] != 'projects': - owners.setdefault(r[3], {})[r[1]] = r[2] - root_opts = {} - if top: - root_opts = owners.pop(top[0][3], {}) - groups = list(owners.values()) - members = top[0][2] or [] - else: - merged = {} - for o in owners.values(): - for k, v in o.items(): merged.setdefault(k, v) - groups, members = [merged], [] - - def one(o, rel): - rd = o.get('rootDir', root_opts.get('rootDir')) - rd = rd[0] if isinstance(rd, list) and rd and isinstance(rd[0], str) else '.' - r = _under(rel, posixpath.join(base, rd.replace('', '.'))) - if r is None: return False - roots = o.get('roots') - if isinstance(roots, list) and roots: - if not any(_under(r, x.replace('', '.')) is not None for x in roots if isinstance(x, str)): - return False - ign = o.get('testPathIgnorePatterns') - for p in (ign if isinstance(ign, list) else ['/node_modules/']): - try: - if isinstance(p, str) and re.search(p.replace('', ''), '/' + r): return False - except re.error: - pass - if 'testRegex' in o: - v = o['testRegex'] - return True if v is None else _pats_match([p if isinstance(p, tuple) else ('re', p) for p in v], r) - m = o.get('testMatch', list(_JEST_MATCH)) - if m is None: return True - return _pats_match([p.replace('/', '') if isinstance(p, str) else p for p in m], r) - def collects(rel): - if any(one(o, rel) for o in groups): return True - return any(_pats_match(_JEST_MATCH, _under(rel, m)) for m in _member_dirs(members, rel) - if not any(os.path.isfile(os.path.join(repo, d, m, c)) for c in _RUNNERS[1][1])) - return collects +def _first_file(repo, d, names): + return next((c for c in names if os.path.isfile(os.path.join(repo, d, c))), None) -def _playwright_collects(text): - recs = _js_keys(text, {'testDir', 'testMatch', 'testIgnore'}) - dirs = [v[0] for _, k, v, _, _ in recs if k == 'testDir' and isinstance(v, list) and v] or ['.'] - match = [p for _, k, v, _, _ in recs if k == 'testMatch' for p in (v or ['**'])] or list(_PW_MATCH) - ignore = [p for _, k, v, _, _ in recs if k == 'testIgnore' for p in (v or [])] +def _members(repo, d, root, pat, under, confs): + """(directory, config file or None), both from d, of each project a `projects` / workspace entry names on the + path `under` (rel from root): a directory glob with its own config (the first of `confs`), or a config glob.""" + pat = posixpath.normpath(pat[2:] if pat.startswith('./') else pat) + for m in _member_dirs([pat], under): + mdir = posixpath.normpath(posixpath.join(root, m)) + full = os.path.join(repo, d, mdir) + if re.search(r'\.(c|m)?[jt]s$|\.json$', pat): + names = sorted(os.listdir(full)) if os.path.isdir(full) else [] + conf = next((c for c in names if _globs_match((pat,), posixpath.normpath(posixpath.join(m, c)))), None) + if conf: yield mdir, posixpath.join(mdir, conf) + else: + yield mdir, _first_file(repo, d, [posixpath.join(mdir, c) for c in confs]) + + +# How vitest decides what it collects, from the directory it runs in: +# - its config is vitest.config.*, else vite.config.* (a `test` block there is vitest's), else none (defaults); +# `--config x` on the command line is another; +# - the config is what the file exports: through defineConfig, a config function, mergeConfig, a const, a +# spread, a shorthand property, a re-export or an import of a file beside it; +# - `test.projects` (or `test.workspace`, or a vitest.workspace.* file) makes each project collect on its own — +# an inline project from the root (with the root's options only when it says `extends: true`), a directory +# from its own vitest.config.* / vite.config.*, rooted there; +# - a project collects a file under `test.dir` (else `root`, else the directory vitest runs in) that its +# `test.include` (default **/*.{test,spec}.?(c|m)[jt]s?(x)) or `includeSource` takes and its `test.exclude` +# (default node_modules, dist, …) does not. `coverage.include` is coverage's, never the test include. +# What the config does not say literally is not read as the default: an include this cannot read collects the +# file, an exclude this cannot read excludes nothing. +def _vitest_takes(repo, d, cfg, ws, rel, root='.', depth=0): + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + if cfg is None: cfg = {} + if not isinstance(cfg, dict): return True + test = _get(cfg, 'test') + if test is None: test = {} + if not isinstance(test, dict): return True + r = _get(test, 'root') + if r is None: r = _get(cfg, 'root') + if r is not None and not isinstance(r, str): return True + root = posixpath.normpath(posixpath.join(root, r)) if r else root + projects = _get(test, 'projects') + if projects is None: projects = _get(test, 'workspace') + if isinstance(projects, str): + p = os.path.join(repo, d, root, projects) + projects = _js_config(p) if os.path.isfile(p) else _UNK + if projects is None and ws: + projects = _js_config(os.path.join(repo, d, ws)) + if projects is not None: + if depth > 2 or not isinstance(projects, list): return True + return any(_vitest_member_takes(repo, d, cfg, test, p, rel, root, depth) for p in projects) + dr = _get(test, 'dir') + if dr is not None and not isinstance(dr, str): return True + base = posixpath.normpath(posixpath.join(root, dr)) if dr else root + r = _under(rel, base) + if r is None: return False + exc = _pat_list(_get(test, 'exclude')) + if _pats_take(list(_VITEST_EXCLUDE) if exc is None else exc, r, False): return False + inc = _pat_list(_get(test, 'include')) + src = _pat_list(_get(test, 'includeSource')) + return bool(_pats_take(list(_VITEST_INCLUDE) if inc is None else inc, r, True) + or src is not None and _pats_take(src, r, False)) + + +def _vitest_member_takes(repo, d, cfg, test, p, rel, root, depth): + p = p.ret if isinstance(p, _Fn) else p + if isinstance(p, dict): + # an inline project: rooted where the root config is; it has the root's options only with `extends: true` + own = dict(p) + if _get(own, 'extends') is True: + base = {k: v for k, v in cfg.items() if k != 'test'} + base['test'] = {k: v for k, v in test.items() if k not in ('projects', 'workspace')} + own = _merge(base, {k: v for k, v in own.items() if k != 'extends'}) + return _vitest_takes(repo, d, own, None, rel, root, depth + 1) + if not isinstance(p, str): return True + under = _under(rel, root) + if p.startswith('!') or under is None: return False + # a directory is a project with its own vitest.config.* / vite.config.*, or vitest's defaults, rooted there + return any(_vitest_takes(repo, d, _js_config(os.path.join(repo, d, conf)) if conf else {}, None, rel, mdir, + depth + 1) + for mdir, conf in _members(repo, d, root, p, under, _VITEST_CONFS + _VITE_CONFS)) + + +def _vitest_collects(repo, d, conf, ws=None): + """How vitest run from `d` with config `conf` (a path from d, None for none) collects: a function of the path + from d, True when vitest collects it or its config does not say.""" + cfg = _js_config(os.path.join(repo, d, conf)) if conf else {} + return lambda rel: _vitest_takes(repo, d, cfg, ws, rel) + + +# How jest decides what it collects: its config (jest.config.*, the package.json `jest` key, or `--config x`) as +# the file exports it; `projects` makes each project collect on its own (a directory from its own config, rooted +# there); a file collects when it is under rootDir (default: the config's directory) and one of `roots`, no +# testPathIgnorePatterns regex matches it, and testRegex (when set) or testMatch takes it — testMatch in order, +# a `!glob` taking back what an earlier glob took. collectCoverageFrom and the coverage* keys are coverage's. +def _jest_globs(globs, rel): + kept, negatives, unread = None, 0, False + for g in globs: + if isinstance(g, tuple) and g[:1] == ('re',): + g = g[1] + if not isinstance(g, str): + unread = True; continue + g = g.replace('/', '') + if g.startswith('!'): + negatives += 1 + if _globs_match((g[1:],), rel): kept = False + elif _globs_match((g,), rel): + kept = True + if unread and kept is not False: return True + return kept is not False if negatives == len(globs) else bool(kept) + + +def _jest_takes(repo, d, cfg, base, rel, depth=0): + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + if cfg is None: cfg = {} + if not isinstance(cfg, dict): return True + rd = _get(cfg, 'rootDir') + if rd is not None and not isinstance(rd, str): return True + root = posixpath.normpath(posixpath.join(base, rd.replace('', '.'))) if rd else base + projects = _get(cfg, 'projects') + if projects is not None: + if depth > 2 or not isinstance(projects, list): return True + for p in projects: + p = p.ret if isinstance(p, _Fn) else p + if isinstance(p, dict): + if _jest_takes(repo, d, p, root, rel, depth + 1): return True + continue + if not isinstance(p, str): return True + under = _under(rel, root) + if under is None or p.startswith('!'): continue + for mdir, conf in _members(repo, d, root, p.replace('', '.'), under, _RUNNERS[1][1]): + mcfg = _js_config(os.path.join(repo, d, conf)) if conf else \ + (_pkg_json(repo, posixpath.join(d, mdir)) or {}).get('jest') or {} + if _jest_takes(repo, d, mcfg, mdir, rel, depth + 1): return True + return False + r = _under(rel, root) + if r is None: return False + roots = _get(cfg, 'roots') + if isinstance(roots, list) and roots and all(isinstance(x, str) for x in roots): + if not any(_under(r, x.replace('', '.')) is not None for x in roots): return False + ign = _get(cfg, 'testPathIgnorePatterns') + for p in (ign if isinstance(ign, list) else ['/node_modules/'] if ign is None else []): + try: + if isinstance(p, str) and re.search(p.replace('', ''), '/' + r): return False + except re.error: + pass + rx = _pat_list(_get(cfg, 'testRegex')) + if rx is _UNK: return True + if rx: + return bool(_pats_take([p if isinstance(p, tuple) or not isinstance(p, str) else ('re', p) for p in rx], + r, True)) + tm = _pat_list(_get(cfg, 'testMatch')) + if tm is _UNK: return True + return _jest_globs(list(_JEST_MATCH) if tm is None else tm, r) + + +def _jest_collects(repo, d, cfg, base): + """How jest run from `d` with the config value `cfg` (from the file at `base`, relative to d) collects.""" + return lambda rel: _jest_takes(repo, d, cfg, base, rel) + + +def _playwright_collects(repo, d, conf): + """playwright: testDir (default the config's directory), testMatch, testIgnore — top level or per project.""" + cfg = _js_config(os.path.join(repo, d, conf)) if conf else {} + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + + def one(o, top, rel): + get = lambda k: _get(o, k) if _get(o, k) is not None else _get(top, k) + td = get('testDir') + if td is not None and not isinstance(td, str): return True + r = _under(rel, posixpath.normpath(td) if td else '.') + if r is None: return False + ign = _pat_list(get('testIgnore')) + if ign and _pats_take(ign, r, False): return False + m = _pat_list(get('testMatch')) + return bool(_pats_take(list(_PW_MATCH) if m is None else m, r, True)) def collects(rel): - for dr in dirs: - r = _under(rel, dr) - if r is not None and not (ignore and _pats_match(ignore, r)) and _pats_match(match, r): return True - return False + if not isinstance(cfg, dict): return True + projects = _get(cfg, 'projects') + if projects is _UNK: return True + if isinstance(projects, list) and projects: + return any(not isinstance(p, dict) or one(p, cfg, rel) for p in projects) + return one({}, cfg, rel) return collects @@ -495,6 +1103,69 @@ def _node_test(words): return None +_MOCHA_VALUED = {'--require', '-r', '--extension', '--config', '--reporter', '-R', '--timeout', '-t', '--ui', '-u', + '--file', '--ignore', '--exclude', '--spec', '--grep', '-g', '--fgrep', '-f', '--slow', '-s', + '--reporter-option', '--reporter-options', '-O', '--node-option', '-n', '--package', '--jobs', '-j', + '--retries', '--import', '--loader', '--watch-files', '--watch-ignore'} + + +def _mocha_rc(repo, d, conf): + """A .mocharc as a dict (spec, extension, recursive, ignore); YAML read for those keys only.""" + if not conf.endswith(('.yml', '.yaml')): + return _js_config(os.path.join(repo, d, conf)) + text, out = _read(os.path.join(repo, d, conf)), {} + for key in ('spec', 'extension', 'ignore', 'recursive'): + m = re.search(r'^' + key + r':[ \t]*(.*)$((?:\n[ \t]+-.*)*)', text, re.M) + if not m: continue + vals = [s.strip(' \'"-[]') for s in ([m.group(1)] if m.group(1).strip() else []) + m.group(2).split('\n')] + vals = [x.strip(' \'"') for v in vals for x in v.split(',') if x.strip(' \'"')] + out[key] = (vals[0] == 'true') if key == 'recursive' and vals else vals + return out + + +# How mocha decides what it runs: the specs on its command line (and --spec), else the config's `spec`, else the +# ./test directory; a directory spec takes the files in it (and below it with --recursive) with one of the +# --extension / `extension` extensions (default js, cjs, mjs); --ignore takes files back out. The command keeps +# the script's own flags (--require tsx/cjs …), since a TypeScript spec does not load without them. +def _mocha_run(repo, d, rc, args): + """(collects(path from d), the flags to run one file with) for mocha run with `args` over the config `rc`.""" + rc = rc if isinstance(rc, dict) else _UNK + specs, exts, ignore, flags, rec, i = [], [], [], [], False, 0 + while i < len(args): + a = args[i] + key, _, inline = a.partition('=') + val = inline if inline else (args[i + 1] if key in _MOCHA_VALUED and i + 1 < len(args) else None) + step = 1 if inline or key not in _MOCHA_VALUED else 2 + if key == '--config' and val and os.path.isfile(os.path.join(repo, d, val)): + rc = _mocha_rc(repo, d, val) + if key == '--spec' and val: specs.append(val) + elif key == '--extension' and val: exts += val.split(',') + elif key in ('--ignore', '--exclude') and val: ignore.append(val) + elif key == '--recursive': rec = True + if not a.startswith('-'): + specs.append(a) + elif key not in ('--spec', '--watch', '-w', '--watch-files', '--watch-ignore'): + flags += args[i:i + step] + i += step + + def opt(k): + v = _get(rc, k) if rc is not _UNK else _UNK + return v if v is _UNK or v is None or isinstance(v, (list, bool)) else [v] if isinstance(v, str) else _UNK + + rspec, rext, rign = opt('spec'), opt('extension'), opt('ignore') + if _UNK in (rspec, rext): return (lambda rel: True), flags + exts = exts or rext or ['js', 'cjs', 'mjs'] + rec = rec or opt('recursive') is True + globs = [] + for s in specs + (rspec or []) or ['test']: + if not isinstance(s, str): return (lambda rel: True), flags + s = s[2:] if s.startswith('./') else s + if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', s): globs.append(s) + else: globs.append(s.rstrip('/') + ('/**/*.{' if rec else '/*.{') + ','.join(e.lstrip('.') for e in exts) + '}') + ignore += [g for g in (rign if isinstance(rign, list) else []) if isinstance(g, str)] + return (lambda rel: _pats_match(globs, rel) and not _pats_match(ignore, rel)), flags + + @functools.lru_cache(maxsize=None) def _runners_of(repo, d): """[(runner, collects(path from d), command)] the directory `d` runs tests with, the configured ones first.""" @@ -507,63 +1178,54 @@ def _runners_of(repo, d): def conf_of(configs): return next((c for c in configs if os.path.isfile(os.path.join(repo, d, c))), None) - # vitest: vitest.config.* and the workspace file, else vite.config.* when vitest is what the package uses - vconfs = [c for c in _RUNNERS[0][1] if os.path.isfile(os.path.join(repo, d, c))] + # vitest: vitest.config.* (else vite.config.* when the package uses vitest) and the workspace file, and each + # `vitest --config x` a script runs uses_vitest = 'vitest' in deps or re.search(r'\bvitest\b', said) - vite = conf_of(tuple(f'vite.config.{e}' for e in _CONF_EXT)) - if not any(c.startswith('vitest.config.') for c in vconfs) and vite and uses_vitest: - vconfs.append(vite) - if vconfs or uses_vitest: - texts = [_read(os.path.join(repo, d, c)) for c in vconfs] - strength = 3 if vconfs and any(re.search(r'\btest\s*:', t) or c.startswith('vitest.') for c, t in - zip(vconfs, texts)) else 2 if re.search(r'\bvitest\b', said) else 1 - found.append((strength, 0, 'vitest', _vitest_collects(texts, repo, d), 'npx vitest run')) + vconf = conf_of(_VITEST_CONFS) or (uses_vitest and conf_of(_VITE_CONFS)) or None + ws = conf_of(_VITEST_WS) + if vconf or ws or uses_vitest: + cfg = _js_config(os.path.join(repo, d, vconf)) if vconf else None + strength = 3 if ws or vconf and (vconf.startswith('vitest.') or _get(cfg, 'test') is not None + or re.search(r'\btest\b', _read(os.path.join(repo, d, vconf)))) \ + else 2 if re.search(r'\bvitest\b', said) else 1 + found.append((strength, 0, 'vitest', _vitest_collects(repo, d, vconf, ws), 'npx vitest run')) + for s in scripts: + m = re.search(r'\bvitest\b[^&|;]*?(?:--config[= ]|-c\s+)(["\']?)([^\s"\'{]+)\1', s) + if m and os.path.isfile(os.path.join(repo, d, m.group(2))) and m.group(2) != vconf: + found.append((3, 0, 'vitest', _vitest_collects(repo, d, m.group(2)), + f'npx vitest run --config {shlex.quote(m.group(2))}')) # jest: its config file or package.json key, each `jest --config x` a script runs, react-scripts' own jest jconf = conf_of(_RUNNERS[1][1]) uses_jest = 'jest' in deps or re.search(r'\bjest\b', said) if jconf or 'jest' in pj: - text = _read(os.path.join(repo, d, jconf)) if jconf else json.dumps({'x': pj.get('jest')}) - found.append((3, 1, 'jest', _jest_collects(text, '.', repo, d), 'npx jest')) + cfg = _js_config(os.path.join(repo, d, jconf)) if jconf else pj.get('jest') + found.append((3, 1, 'jest', _jest_collects(repo, d, cfg, '.'), 'npx jest')) elif uses_jest: - found.append((2 if re.search(r'\bjest\b', said) else 1, 1, 'jest', _jest_collects('', '.', repo, d), 'npx jest')) + found.append((2 if re.search(r'\bjest\b', said) else 1, 1, 'jest', _jest_collects(repo, d, {}, '.'), + 'npx jest')) for s in scripts: m = re.search(r'\bjest\b[^&|;]*?(?:--config[= ]|-c\s+)(["\']?)([^\s"\'{]+)\1', s) if m and os.path.isfile(os.path.join(repo, d, m.group(2))): p = m.group(2) base = posixpath.dirname(p[2:] if p.startswith('./') else p) or '.' - found.append((3, 1, 'jest', _jest_collects(_read(os.path.join(repo, d, p)), base, repo, d), + found.append((3, 1, 'jest', _jest_collects(repo, d, _js_config(os.path.join(repo, d, p)), base), f'npx jest --config {shlex.quote(p)}')) if re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s): tool = re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s).group(1) found.append((2, 1, 'jest', lambda rel: _pats_match(_CRA_MATCH, rel), f'npx {tool} test --watchAll=false')) - # mocha: .mocharc spec, the package.json key, or the specs its script names + # mocha: .mocharc / the package.json key, and each script that runs mocha with its own flags and specs mconf = conf_of(_RUNNERS[2][1]) if mconf or 'mocha' in pj or 'mocha' in deps or re.search(r'\bmocha\b', said): - text = _read(os.path.join(repo, d, mconf)) if mconf else json.dumps({'x': pj.get('mocha') or {}}) - if mconf and mconf.endswith(('.yml', '.yaml')): - m = re.search(r'^spec:\s*(.*)$((?:\n\s+-.*)*)', text, re.M) - spec = ([s.strip(' \'"-') for s in ([m.group(1)] if m.group(1).strip() else []) + m.group(2).split('\n') - if s.strip(' \'"-')] if m else []) - else: - spec = next((r[2] for r in _js_keys(text, {'spec'})), []) - for words in (_script_words(s) for s in scripts): - for ws in words: - if 'mocha' in [posixpath.basename(w) for w in ws]: - i = [posixpath.basename(w) for w in ws].index('mocha') - rec = '--recursive' in ws - for x in ws[i + 1:]: - if x.startswith('-') or spec is None: continue - x = x[2:] if x.startswith('./') else x - if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', x): spec.append(x) - elif re.fullmatch(r'[\w./-]+', x): - spec.append(x.rstrip('/') + ('/**/*.{js,cjs,mjs}' if rec else '/*.{js,cjs,mjs}')) - spec = None if spec is None else [s[2:] if isinstance(s, str) and s.startswith('./') else s for s in spec] - found.append((3 if mconf or 'mocha' in pj else 2 if re.search(r'\bmocha\b', said) else 1, 2, 'mocha', - (lambda rel, spec=spec: True if spec is None else _pats_match(spec or _MOCHA_SPEC, rel)), - 'npx mocha')) + rc = _mocha_rc(repo, d, mconf) if mconf else pj.get('mocha') or {} + strength = 3 if mconf or 'mocha' in pj else 2 if re.search(r'\bmocha\b', said) else 1 + runs = [ws[[posixpath.basename(w) for w in ws].index('mocha') + 1:] + for s in scripts for ws in _script_words(s) if 'mocha' in [posixpath.basename(w) for w in ws]] + for args in runs or [[]]: + collects, flags = _mocha_run(repo, d, rc, args) + found.append((strength, 2, 'mocha', collects, ' '.join(['npx mocha'] + [shlex.quote(f) for f in flags]))) # node's own runner: `node --test` / `tsx --test` in a script for s in scripts: @@ -576,8 +1238,7 @@ def _runners_of(repo, d): pconf = conf_of(_RUNNERS[3][1]) if pconf or '@playwright/test' in deps or 'playwright' in deps or re.search(r'\bplaywright\s+test\b', said): found.append((3 if pconf else 2 if re.search(r'\bplaywright\s+test\b', said) else 1, 4, 'playwright', - _playwright_collects(_read(os.path.join(repo, d, pconf)) if pconf else ''), - 'npx playwright test')) + _playwright_collects(repo, d, pconf), 'npx playwright test')) found.sort(key=lambda x: (-x[0], x[1])) return [(name, collects, cmd) for _, _, name, collects, cmd in found] diff --git a/tests/test_command.py b/tests/test_command.py index f8081584b..b045d78eb 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -216,6 +216,94 @@ def tree(files): {'package.json': {'scripts': {'lint': 'eslint "test/**/*.js"'}}}, 'test/test-users.js', ([], ['test/test-users.js'])), ("a package directory with a space is quoted", {'my pkg/package.json': V}, 'my pkg/src/a.test.ts', (["(cd 'my pkg' && npx vitest run src/a.test.ts)"], [])), + # the config is read as the value the file exports, not as the literal keys it spells; what that value does not + # say is never taken as the runner's defaults + ("vitest: vitest.config.* outranks vite.config.* (control)", + {'package.json': V, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}", + 'vitest.config.mjs': "export default {test:{include:['spec/**/*.ts']}}"}, + 'tests/unit/a.ts', ([], ['tests/unit/a.ts'])), + ("vitest: an include spread from configDefaults keeps the defaults", + {'package.json': V, 'vitest.config.ts': "import { defineConfig, configDefaults } from 'vitest/config'\n" + "export default defineConfig({ test: { include: [...configDefaults.include, 'checks/**/*.ts'] } })"}, + 'src/a.test.ts', (['npx vitest run src/a.test.ts'], [])), + ("vitest: test options aliased through a const `{ test }`", + {'package.json': V, 'vitest.config.ts': "import { defineConfig } from 'vitest/config'\n" + "const test = { include: ['spec/**/*.ts'] }\nexport default defineConfig({ test })"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: the aliased include leaves a default-named file out (control)", + {'package.json': V, 'vitest.config.ts': "import { defineConfig } from 'vitest/config'\n" + "const test = { include: ['spec/**/*.ts'] }\nexport default defineConfig({ test })"}, + 'src/b.test.ts', ([], ['src/b.test.ts'])), + ("vitest: include as a shorthand property `{ include }`", + {'package.json': V, 'vitest.config.ts': "const include: string[] = ['spec/**/*.ts']\n" + "export default defineConfig({ test: { include } })"}, + 'src/b.test.ts', ([], ['src/b.test.ts'])), + ("vitest: a config re-exported from another file", + {'package.json': V, 'vitest.config.ts': "export { default } from './config/vitest.shared'\n", + 'config/vitest.shared.ts': "import { defineConfig } from 'vitest/config'\n" + "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: mergeConfig with its base include in another file", + {'package.json': V, 'vitest.base.ts': "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })", + 'vitest.config.ts': "import { mergeConfig, defineConfig } from 'vitest/config'\nimport base from './vitest.base'\n" + "export default mergeConfig(base, defineConfig({ test: { exclude: ['spec/skip/**'] } }))"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: the merged exclude still takes its file out (control)", + {'package.json': V, 'vitest.base.ts': "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })", + 'vitest.config.ts': "import { mergeConfig, defineConfig } from 'vitest/config'\nimport base from './vitest.base'\n" + "export default mergeConfig(base, defineConfig({ test: { exclude: ['spec/skip/**'] } }))"}, + 'spec/skip/b.ts', ([], ['spec/skip/b.ts'])), + ("vitest: an include computed at load time is not read as the defaults", + {'package.json': V, 'vitest.config.ts': "const include = ['spec'].map(d => d + '/**/*.ts')\n" + "export default { test: { include } }"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: a workspace member configured by its vite.config.ts test block", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/package.json': {'name': 'a'}, + 'packages/a/vite.config.ts': "export default defineConfig({ test: { include: ['tests/**/*.ts'] } })"}, + 'packages/a/tests/x.ts', (['npx vitest run packages/a/tests/x.ts'], [])), + ("vitest: a second config a script names with --config", + {'package.json': {**V, 'scripts': {'test': 'vitest run', 'test:e2e': 'vitest run --config vitest.e2e.config.ts'}}, + 'vitest.config.ts': "export default defineConfig({ test: { include: ['src/**/*.test.ts'] } })", + 'vitest.e2e.config.ts': "export default defineConfig({ test: { include: ['e2e/**/*.e2e.ts'] } })"}, + 'e2e/login.e2e.ts', (['npx vitest run --config vitest.e2e.config.ts e2e/login.e2e.ts'], [])), + ("vitest: test.dir narrows where it looks", + {'package.json': V, 'vitest.config.ts': "export default defineConfig({ test: { dir: 'src' } })"}, + 'test/b.test.ts', ([], ['test/b.test.ts'])), + ("vitest: test.dir keeps its own files (control)", + {'package.json': V, 'vitest.config.ts': "export default defineConfig({ test: { dir: 'src' } })"}, + 'src/a.test.ts', (['npx vitest run src/a.test.ts'], [])), + ("jest: a testMatch spread from jest-config's defaults keeps the defaults", + {'package.json': J, 'jest.config.js': "const { defaults } = require('jest-config')\n" + "module.exports = { testMatch: [...defaults.testMatch, '**/*.int.js'] }"}, + 'src/a.test.js', (['npx jest src/a.test.js'], [])), + ("jest: a `!glob` in testMatch takes a file back out", + {'package.json': J, 'jest.config.js': "module.exports = { testMatch: ['**/*.test.js', '!**/fixtures/**'] }"}, + 'src/fixtures/a.test.js', ([], ['src/fixtures/a.test.js'])), + ("jest: the negation keeps every other file (control)", + {'package.json': J, 'jest.config.js': "module.exports = { testMatch: ['**/*.test.js', '!**/fixtures/**'] }"}, + 'src/b.test.js', (['npx jest src/b.test.js'], [])), + ("jest: collectCoverageFrom is not the test match", + {'package.json': J, 'jest.config.js': "module.exports = { collectCoverageFrom: ['lib/**/*.js'] }"}, + 'test/a.test.js', (['npx jest test/a.test.js'], [])), + ("jest: nor in the package.json key", + {'package.json': {**J, 'jest': {'collectCoverageFrom': ['lib/**/*.js'], 'coveragePathIgnorePatterns': ['/test/']}}}, + 'test/a.test.js', (['npx jest test/a.test.js'], [])), + ("jest: a config through a const and an async function", + {'package.json': J, 'jest.config.ts': "import type { Config } from 'jest'\n" + "const config: Config = { testMatch: ['**/*.it.ts'] }\nexport default async () => config"}, + 'src/a.it.ts', (['npx jest src/a.it.ts'], [])), + ("mocha: --extension ts --recursive in the script, run with the script's flags", + {'package.json': {'scripts': {'test': 'mocha --require tsx/cjs --extension ts --recursive test'}, + 'devDependencies': {'mocha': '10'}}}, + 'test/unit/a.spec.ts', (['npx mocha --require tsx/cjs --extension ts --recursive test/unit/a.spec.ts'], [])), + ("mocha: .mocharc.json extension with the default ./test spec", + {'package.json': {'scripts': {'test': 'mocha'}, 'devDependencies': {'mocha': '10'}}, + '.mocharc.json': {'extension': ['ts'], 'require': 'tsx/cjs'}}, + 'test/a.ts', (['npx mocha test/a.ts'], [])), + ("mocha: without --recursive a nested file is not collected (control)", + {'package.json': {'scripts': {'test': 'mocha --extension ts test'}, 'devDependencies': {'mocha': '10'}}}, + 'test/unit/a.ts', ([], ['test/unit/a.ts'])), ]: d = tree(files) check(why, ti.js_plan(d, [f]), want) From ac3da6826761a799f2e5a6ddafe1cd253cfca099 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:35:38 -0700 Subject: [PATCH 25/37] javascript: a const holding a wrapped handler is registered at its route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const h = catchAsync(async (req, res) => …)` then `router.get('/a', h)` printed the route line as '[by name] … reads it'. A const read on a route line by the callable making the route call is now a registration: [registered] when the wrapper's returned function is what the line hands over, [by name] with the route wording for a library wrapper. router.route(p).get(h) names GET, a file:line on a const targets the const, and a file declaring its own same-named const or function no longer merges in. Both backends (Datalog rules and the SQL fallback). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 9 +++ .../axiomcode/scripts/ax_registration.py | 51 ++++++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 23 +++++- .../skills/axiomcode/scripts/dl/impact.dl | 61 ++++++++++++--- .../skills/axiomcode/scripts/graph_sql.py | 45 ++++++++++- .../wrapped-handler-route/case.json | 74 +++++++++++++++++++ .../src/controllers/adminController.js | 5 ++ .../src/controllers/arrowController.js | 2 + .../src/controllers/libController.js | 3 + .../src/controllers/report.controller.js | 3 + .../wrapped-handler-route/src/routes/admin.js | 9 +++ .../src/routes/chained.js | 8 ++ .../wrapped-handler-route/src/routes/lib.js | 7 ++ .../wrapped-handler-route/src/routes/local.js | 6 ++ .../wrapped-handler-route/src/routes/other.js | 3 + .../src/routes/qualified.js | 7 ++ .../src/routes/routed.js | 7 ++ .../src/services/adminService.js | 3 + .../src/utils/catchAsync.js | 2 + 19 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 tests/cases/javascript/wrapped-handler-route/case.json create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/admin.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/chained.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/lib.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/local.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/other.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/routed.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/services/adminService.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 3ad1d58f1..c83569891 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -479,3 +479,12 @@ def best_cert(certs): def direct_cert(tier): """how sure a DIRECT dependent row is, from the edge's tier.""" return DIRECT_CERT.get(tier, DIRECT_CERT_DEFAULT) + + +def site_lines(start, end, cap=40): + """every line a call site spans. A chained call (`router\\n .route('/')\\n .post(h)`) records its edges on the + statement's first line while an argument sits on a later one. Capped: a site spanning a whole callback body is + not a hand-off on every line of it.""" + start = start or 0 + if not end or end <= start or end - start > cap: return [start] + return list(range(start, end + 1)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 6b9f12166..7f896a0dd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,8 +16,11 @@ here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166). """ +import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} +# the verbs that name an HTTP method; `all`, `use` and `route` register or mount without naming one +HTTP_VERB = ROUTE_VERB - {'all', 'use', 'route'} # A CALLBACK IS NOT RECOGNISED BY THE VERB ALONE, and a curated list of verbs was the wrong instrument. # The route leg has two independent signals -- an HTTP verb AND a path-shaped literal on the same line -- and it is @@ -80,7 +83,8 @@ def registrations(q, site_file=None): # handler does depend on it — so only the key is withheld. tf = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} if _has(q, 'symbols') else set() out = {} - for name, site_kind, fp, a, b in q("""SELECT callee_name, kind, file_path, start_line, end_line FROM call_sites + verbs = {} # (file, line) -> {HTTP verb: first column}, for the wording below + for name, site_kind, fp, a, b, col in q("""SELECT callee_name, kind, file_path, start_line, end_line, end_column FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"""): f = sf(fp); b = b or a if b < a or b - a > MAX_SITE_SPAN: @@ -98,9 +102,54 @@ def registrations(q, site_file=None): # a route beats a plain callback on the same line: `router.use('/x', wrap(handler))` is a route site if (f, l) not in out or kind == 'route': out[(f, l)] = (kind, key, why) + if short.lower() in HTTP_VERB and (l == b or a == b): + vs = verbs.setdefault((f, l), {}); vs[short.upper()] = min(vs.get(short.upper(), col or 0), col or 0) + # `router.route('/').get(h)` is two route-shaped calls on one line, and `route` said nothing about the method: the + # line's HTTP verbs name it, in the order written. A chain registering two (`.get(a).post(b)`) names both, because + # a line is all a reference records and it cannot say which argument list the handler sat in + for (f, l), (k, key, w) in list(out.items()): + vs = verbs.get((f, l)) + if k == 'route' and vs: + m = re.match(r'registered as a \w+ route (".*?") here', w) + if m: out[(f, l)] = (k, key, f"registered as a {'/'.join(sorted(vs, key=vs.get))} route {m.group(1)} here — the router calls it, no call site does") return sorted((ref_at.get((f, l), ''), f, l, k, key, w) for (f, l), (k, key, w) in out.items()) +# A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a +# field, not a method, so it has no call edge of its own to carry the route wording. The three facts below are what +# both backends join instead (`const_route` in dl/impact.dl, graph_sql.direct_for_field): + +def route_calls(q, site_file=None): + """{(caller, file, line)}: the callable making a route-shaped call (`get`, `route`, `use` …), on every line the call + spans. A name read on a route line by THAT callable is an argument of the registration; one read inside an inline + handler on the same line (`router.get('/n', (req, res) => res.json(count))`) belongs to the handler, and is a read.""" + import ax_edges + sf = site_file or (lambda x: x) + out = set() + for c, name, fp, a, b in q("""SELECT caller_id, callee_name, file_path, start_line, end_line FROM call_sites + WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): + if name.split('.')[-1].lower() in ROUTE_VERB: + out.update((c, sf(fp), l) for l in ax_edges.site_lines(a, b)) + return out + + +def init_call_lines(q, site_file=None): + """{(file, line)}: lines a call starts on, other than `require(…)`. A const declared on one holds what a call + returned; `const ctrl = require('./ctrl')` holds a module, which is read on a route line, not registered there.""" + sf = site_file or (lambda x: x) + return {(sf(fp), a) for name, fp, a in q("""SELECT callee_name, file_path, start_line FROM call_sites + WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL""") + if name.split('.')[-1] != 'require'} + + +def returned_functions(q): + """{(wrapper, fn)}: the engine's own `ext_return_value` — the function a callable returns. `catchAsync` returns + `(req, res, next) => …`, and that is the function the route line hands over for every const it wrapped.""" + if not _has(q, 'ext_return_value'): + return set() + return {(w, m) for w, k, m in q("SELECT c0, c1, c2 FROM ext_return_value") if k == 'func' and m} + + def value_ref_rows(q, names, ids, site_file=None, at=None, lines=None): """The SQL side of the rule: [(caller, 'uses', why, 'by name', file, line)] for a method target. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e74bbfa1f..dc9129448 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -449,6 +449,12 @@ class Impact: if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: out.append(('type', f"{self.g.sym[tids[0]]['kind']} {self.g.disp(tids[0])}" + (f" (+{len(tids)-1})" if len(tids) > 1 else ''), tids)) + # a const declared ON the line is the declaration written there: the enclosing module spans the line and a + # function in its initializer (`const h = wrap(async (req, res) => …)`) starts on it, and neither is what was asked + # — unless that function IS the const (`const h = async (req, res) => …` is named `h`), which stays the method + if kind is None and out and out[0][0] == 'field' and re.fullmatch(r'.+\.\w+:\d+', s): + named = {self.g.sym[i]['name'] for i in self.methods(s, soft=True) if self.g.sym[i].get('line') == out[0][2][0]['line']} + if not named & {f['name'] for f in out[0][2]}: return out if kind in (None, 'method'): mids = self.methods(s, soft=True) # `Type` written alone is the type, and its construction is Type., so a bare type name must not @@ -522,6 +528,12 @@ class Impact: if soft: return [] die(f"no type named {s}") def field_rows(self, s): + # `file.js:12` — the field or const DECLARED on that line. Split on `.` it named a field `js:12`, so a const was + # the one declaration a file:line could not target, and its bare name answered for every const so named + fl = re.fullmatch(r'(.+\.\w+):(\d+)', s) + if fl: + p, n = fl.group(1), int(fl.group(2)) + return [f for f in self.fields.values() if f['line'] == n and (f['file'] == p or (f['file'] or '').endswith('/' + p))] parts = s.split('.'); name = parts[-1]; owner = '.'.join(parts[:-1]) rows = [f for f in self.fields.values() if f['name'] == name and (not owner or (f['owner'] or '') == owner or (f['owner'] or '').endswith('.' + owner) or (f['qualified_name'] or '').endswith('.' + s))] if not rows and '.' in s: # the dotted spelling, only after the written one @@ -698,7 +710,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '35' # 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 = '36' # 36: handoff_at, route_call, init_call, 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 @@ -878,6 +890,15 @@ class Impact: stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() 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 '', 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")]) + # 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( + """SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL AND s.file_path IS NOT NULL""") + for l in ax_edges.site_lines(r['start_line'], r['end_line'])})) + W('route_call', sorted(ax_registration.route_calls(g.q, g.site_file))) + W('init_call', sorted(ax_registration.init_call_lines(g.q, g.site_file))) + W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) # is the kind "stub": still a name match for the rows, never a by-name seed of the closure diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 970c8da10..9326ec00a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -39,6 +39,7 @@ .decl edge(a:symbol, b:symbol, t:symbol) .input edge .decl named(n:symbol, m:symbol) .input named .decl calls(c:symbol, m:symbol, tier:symbol, f:symbol, l:number) .input calls +.decl handoff_at(c:symbol, m:symbol, f:symbol, l:number) .input handoff_at .decl unresolved(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input unresolved .decl named_site(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input named_site .decl member(t:symbol, s:symbol, n:symbol, k:symbol) .input member @@ -426,6 +427,39 @@ fref(q, c, rk, f, l) :- enum_member_target(q, fl), field(fl, t, n, ff, fll), ref .decl frole(rk:symbol, role:symbol, why:symbol) frole("qualified", "uses", "writes/reads it"). frole("bare", "reads", "reads it"). +// A CONST THAT HOLDS A WRAPPED HANDLER is registered where it is handed to a route, not read there. +// `const listAdmins = catchAsync(async (req, res) => …)` then `router.get('/admins', listAdmins)`: the declaration is +// a const, not a method, so the method-target route wording (`route_site` above) never applied and the answer said +// only "[by name] … reads it" at the registration. Both legs need the name read on a route line BY THE CALLABLE +// MAKING THE ROUTE CALL (`route_call`): a read inside an inline handler on that line +// (`router.get('/n', (req, res) => res.json(count))`) is attributed to the handler, and stays a read. +// - registered: the declaration line calls a wrapper, the engine says what function that wrapper returns +// (`returns_fn`), and the route line hands THAT function over (`handoff_at`, on every line the call spans, since a +// chained registration records its edges on the statement's first line and names the handler on a later one). +// A hand-off of some other function on the same line (`.post(protect, admin, h)` hands `admin`) is not evidence. +// - by name: the wrapper is a library's, so nothing says what it returns — the const holds a call's result +// (`init_call`, not a `require`) and is an argument of the registration. The route's wording, the name's rung, +// as for a method named on a route line with no edge (`registered` above). +// Bare (`router.get('/a', listAdmins)`) or qualified through the module (`.post(auth(), userController.createUser)`). +.decl route_call(c:symbol, f:symbol, l:number) .input route_call +.decl init_call(f:symbol, l:number) .input init_call +.decl returns_fn(w:symbol, m:symbol) .input returns_fn +.decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) +const_route_edge(q, c, m, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), calls(_, w, _, ff, fll), returns_fn(w, m), + fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), handoff_at(c, m, f, l). +.decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) +const_route(q, c, cert, f, l) :- const_route_edge(q, c, _, f, l), cert_tier("callback_registered", cert, _). +direct(q, c, "uses", why, cert, f, l) :- const_route(q, c, cert, f, l), registration(_, f, l, "route", _, why). +// the row claims the hand-off edge on that line, so `verified:` looks it up like any registered row +direct_edge(q, c, m) :- const_route_edge(q, c, m, _, _). +.decl const_route_byname(q:symbol, c:symbol, f:symbol, l:number) +const_route_byname(q, c, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), init_call(ff, fll), + fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), !const_route(q, c, _, f, l). +direct(q, c, "uses", why, "by name", f, l) :- const_route_byname(q, c, f, l), registration(_, f, l, "route", _, why). +.decl const_routed(q:symbol, c:symbol, f:symbol, l:number) +const_routed(q, c, f, l) :- const_route(q, c, _, f, l). +const_routed(q, c, f, l) :- const_route_byname(q, c, f, l). + // A field access the ENGINE resolved. The site is bound to THIS declaration, so it is not a name // match against every field of that name, and its direction is known. Sites the engine could not // bind (tier ambiguous_unknown) are left to the name rules below, which is where they belong. @@ -442,15 +476,15 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl fa_known(q, c) :- target(q, "field", fl, _), fa_bound(c, fl, _, _, _). fa_known(q, c) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(c, fl2, _, _, _), fl2 != fl, field(fl2, _, n, _, _). // in scope: inside the owner, a subtype, or a nested type — or the type node itself (a field initializer) -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c). -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c), !const_routed(q, c, f, l). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c), !const_routed(q, c, f, l). // qualified elsewhere: the qualifier written on the line decides — the owner's own name confirms, another type's name excludes direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c). + owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and // TypeScript is always there — and in Python and JavaScript a module-level function has no owner at all, so a // qualified read of a field from one was matched by no rule and vanished from the answer. `price_order` reading @@ -458,15 +492,22 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie // own class touched it. The qualifier decides here exactly as it does above: the owner's own name confirms, // another TYPE's name excludes, and a variable (which is what a Python qualifier usually is) is `by name`. direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c). + !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, _, _, _), !owner(c, _), !scope(t, c). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). +// a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in +// one module and a wrapped handler of the same name in another are two declarations, and each answer carried the +// other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` +// in a service that declares `const createUser = async (body) => …` is not a reader of the controller's wrapped handler +.decl shadowed_in(fl:symbol, n:symbol, f:symbol) +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, _, _), field(fl2, _, n, f, _), fl2 != fl. +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, ff, _), named(n, m), decl_file(m, f), f != ff. // a declared accessor is the field's door. The door is a CALL, so it carries the call's tier like every other // call-derived rule here: a site the engine could not narrow to one declaration is `one of a set`, not `resolved`. // Binding the tier as a wildcard and hardcoding "resolved" made the accessor door the one place in this file that diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 9c6cd0dec..decfcc0ae 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1939,6 +1939,28 @@ def direct_for_field(q, fids, at, code, lines, inside, rel): for f_ in fids: r_ = field_rec(q, f_) if r_ and r_[1]: decl_sites.setdefault(r_[1], set()).add((r_[2], r_[3])) + # const_route / const_route_byname in dl/impact.dl: a const read on a route line by the callable making the route + # call. Registered when its declaration line calls a wrapper whose returned function (`returns_fn`) that line hands + # over; by name when the declaration holds some other call's result. Like the rules, keyed on the QUERY: any target + # declaration qualifies the site. + route_why, rcall, handed_to, ret_of, dcalls, inits = {}, set(), collections.defaultdict(set), collections.defaultdict(set), set(), set() + if decl_sites: + inits = ax_registration.init_call_lines(q, rel) + dsites = {s for ss in decl_sites.values() for s in ss} + if dsites & inits: # init_call(ff,fll) on ANY target declaration + for _d, rf_, rl_, k_, _key, w_ in ax_registration.registrations(q, rel): + if k_ == 'route': route_why[(rf_, rl_)] = w_ + if route_why: + rcall = ax_registration.route_calls(q, rel) + for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) + for w_, fp_, l_ in q("""SELECT 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_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if (rel(fp_) if fp_ else '', l_ or 0) in dsites: dcalls.update(ret_of.get(w_, ())) # calls(_,w,_,ff,fll), returns_fn(w,m) + for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if m_ in dcalls: + for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at + reg_cert = ax_edges.direct_cert('callback_registered') for fid in fids: rec = field_rec(q, fid) if not rec: continue @@ -1986,8 +2008,20 @@ def declares_of(s_): # scope can be both `in scope` (231, the qualifier is the owner's own type name) and `by name` (233, some # other qualifier). Written as an if/elif chain this picked one and lost 19 rows on one field alone. typenames = {r[0] for r in q("SELECT name FROM symbols WHERE type_id IS NOT NULL AND name IS NOT NULL")} + # shadowed_in(fl,n,f): the files declaring ANOTHER field of this name, or a function of this name other than + # in the field's own file, whose bare reads are their own + own_decl = {r[0] for r in q("""SELECT file FROM symbols WHERE name = ? AND rowid <> ? AND method_id IS NULL AND type_id IS NULL + AND kind IN ('field','const','enum_member','variable')""", n, rid)} + own_decl |= {r[0] for r in q("SELECT file FROM symbols WHERE name = ? AND method_id IS NOT NULL AND file IS NOT NULL", n) if r[0] != ff} for c, rk, rf, rl, _e in fref: s_ = owner_of.get(c) + if (rf, rl) in route_why and (c, rf, rl) in rcall: # const_route*: bare or qualified + if handed_to.get((c, rf, rl)): + rows.append((c, 'uses', route_why[(rf, rl)], reg_cert, rf, rl)) + de += [(c, m_) for m_ in handed_to[(c, rf, rl)]] # direct_edge(q,c,m) :- const_route_edge + continue + rows.append((c, 'uses', route_why[(rf, rl)], 'by name', rf, rl)) # const_route_byname + continue role, why = ('uses', 'writes/reads it') if rk == 'qualified' else ('reads', 'reads it') in_scope = (s_ is not None and s_ in scope) or (c in scope) if in_scope: # 228 / 229 @@ -2002,10 +2036,19 @@ def declares_of(s_): rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if not quals: # 235 — no qualifier on the line rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + # the same in a callable with no owner type (a module, a module-level function): `!owner(c, _)` rules + if rk == 'qualified' and s_ is None and c not in scope: + quals = _qualifiers(code, rf, rl, n) + if tname and tname in quals: + rows.append((c, 'uses', 'writes/reads it', 'in scope', rf, rl)) + if tname and any(qn != tname and qn not in typenames and qn not in SELF_QUALIFIERS for qn in quals): + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + if not quals: + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if rk == 'bare': if s_ is not None and s_ not in scope and n not in declares_of(s_): # 238 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) - if s_ is None and c not in scope: # 240 + if s_ is None and c not in scope and rf not in own_decl: # 240 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) # ── the accessors the convention gives the field, and their callers (242 / 243) ──────────────────── for an, role_ in _accessors(n): diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json new file mode 100644 index 000000000..0fd3ee3d2 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -0,0 +1,74 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a handler written as `const listAdmins = catchAsync(async (req, res) => …)` is a const, not a method, so the route wording the method target gets never applied: the registration `router.get('/admins', listAdmins)` was printed as '[by name] … reads it'. The const's declaration hands a function to the wrapper and the route line hands one to the router, both engine edges", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["const listAdmins", "[registered] admin.", "registered as a GET route \"/admins\"", "src/routes/admin.js:5"], + "avoid": ["[by name] admin. src/routes/admin.js:5"]}, + {"why": "the same when the const is declared in the file that registers it: the read was [in scope] and still not a route", + "run": ["impact", "localList"], + "want": ["[registered] local.", "registered as a GET route \"/local\""], + "avoid": ["reads it"]}, + {"why": "a file:line on a const targets the const. It used to resolve to the enclosing (other.js:2) or to the arrow in the initializer (adminController.js:2), so a const could only be asked by its bare name, which answers for every const so named", + "run": ["impact", "src/routes/other.js:2"], + "want": ["const listAdmins [field]", "[in scope] other. src/routes/other.js:3"], + "avoid": ["other. (at src/routes/other.js:2)", "registered as a GET route"]}, + {"why": "and a file that declares its own const of the name reads its own, not the wrapped handler's: other.js's `['root']` is not a reader of the controller's listAdmins", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["other. src/routes/other.js:3"]}, + {"why": "CONTROL: a plain function handler keeps the wording it already had, from the method rule", + "run": ["impact", "plainAdmins"], + "want": ["[registered] admin.", "registered as a GET route \"/admins-plain\""]}, + {"why": "CONTROL: `const arrowAdmins = async (req, res) => …` IS the function, so its file:line stays the method, with the method's route row", + "run": ["impact", "src/controllers/arrowController.js:1"], + "want": ["arrowAdmins (at src/controllers/arrowController.js:1) [method]", "registered as a GET route \"/admins-arrow\""]}, + {"why": "CONTROL: a const that holds a value, read inside an inline handler on a route line, is a read — the inline arrow hands nothing over and the const's initializer is not a function", + "run": ["impact", "adminCount"], + "want": ["[by name] src/routes/admin.js:7 — reads it"], + "avoid": ["registered as a"]}, + {"why": "the handler read through its module (`.get(auth(), adminController.listAdmins)` after `.route(…)`) is registered there too: it was '[by name] … writes/reads it'", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] qualified.", "src/routes/qualified.js:5"], + "avoid": ["writes/reads it"]}, + {"why": "a chained registration split over lines (`router\\n .route('/admins-c')\\n .get(auth(), adminController.listAdmins)`) records its hand-off on the statement's first line and names the handler two lines down: registered there too", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] chained.", "src/routes/chained.js:7"], + "avoid": ["chained. src/routes/chained.js:7 — writes/reads it"]}, + {"why": "a service that declares its own function of the name reads its own in its exports, not the controller's const", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["src/services/adminService.js"]}, + {"why": "a file:line in a file whose name carries a dot (`report.controller.js`) is a file and a line: the dots were read as a package prefix and every such target was refused", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["const listReports [field]"], + "avoid": ["matches no package"]}, + {"why": "CONTROL: a file:line in a file that is not in the graph is still refused", + "run": ["impact", "src/controllers/nope.controller.js:2"], + "expect_error": true, + "avoid": ["[field]", "[method]"]}, + {"why": "the registered row claims the hand-off edge on the route line, so `verified:` finds it: it printed '✗ 1 not found' for every such row", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["all present"], + "avoid": ["not found"]}, + {"why": "`router.route('/reports').get(listReports)` is a GET route: the `route` call on the same line named the method ROUTE", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["[registered] routed.", "registered as a GET route \"/reports\""], + "avoid": ["ROUTE route", "not found"]}, + {"why": "a chain registering two methods on one line names both: a reference records a line, not the argument list it sat in", + "run": ["impact", "plainAdmins"], + "want": ["registered as a GET/POST route \"/both\""], + "avoid": ["ROUTE route"]}, + {"why": "a handler wrapped by a LIBRARY (`asyncHandler` from a package) has no engine edge for what the wrapper returns, so the row keeps the name's rung but says what the line does: it was '[by name] … reads it'", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["[by name] lib. src/routes/lib.js:5 — registered as a GET route \"/lib\""], + "avoid": ["src/routes/lib.js:5 — reads it", "[registered]"]}, + {"why": "CONTROL: a line that also hands over a plain function (`.post(libList, plainAdmins)`) is not evidence for the const: the hand-off is plainAdmins', so the const's row stays [by name]", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["[by name] lib. src/routes/lib.js:6 — registered as a POST route \"/lib-mixed\""], + "avoid": ["[registered] lib."]}, + {"why": "CONTROL: a const holding a module (`const adminController = require(…)`) read on a route line is read there, not registered", + "run": ["impact", "src/routes/chained.js:2"], + "avoid": ["registered as a"]}, + {"why": "CONTROL: a qualified read of a const holding a value, off any route line, stays a read", + "run": ["impact", "adminCount"], + "want": ["src/routes/qualified.js:6 — writes/reads it"], + "avoid": ["registered as a"]} + ]} diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js new file mode 100644 index 000000000..da2033b3b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js @@ -0,0 +1,5 @@ +const catchAsync = require('../utils/catchAsync'); +const listAdmins = catchAsync(async (req, res) => { res.json([]); }); +async function plainAdmins(req, res) { res.json([]); } +const adminCount = 3; +module.exports = { listAdmins, plainAdmins, adminCount }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js new file mode 100644 index 000000000..84bb86847 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js @@ -0,0 +1,2 @@ +const arrowAdmins = async (req, res) => { res.json([]); }; +module.exports = { arrowAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js new file mode 100644 index 000000000..3c0dad615 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js @@ -0,0 +1,3 @@ +const asyncHandler = require('express-async-handler'); +const libList = asyncHandler(async (req, res) => { res.json([]); }); +module.exports = { libList }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js new file mode 100644 index 000000000..d719d3960 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js @@ -0,0 +1,3 @@ +const catchAsync = require('../utils/catchAsync'); +const listReports = catchAsync(async (req, res) => { res.json([]); }); +module.exports = { listReports }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js new file mode 100644 index 000000000..d87b78303 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js @@ -0,0 +1,9 @@ +const express = require('express'); +const { listAdmins, plainAdmins, adminCount } = require('../controllers/adminController'); +const { arrowAdmins } = require('../controllers/arrowController'); +const router = express.Router(); +router.get('/admins', listAdmins); +router.get('/admins-plain', plainAdmins); +router.get('/admins-count', (req, res) => res.json(adminCount)); +router.get('/admins-arrow', arrowAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js new file mode 100644 index 000000000..8e3af8eb9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js @@ -0,0 +1,8 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router + .route('/admins-c') + .get(auth(), adminController.listAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js new file mode 100644 index 000000000..eefdaca47 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { libList } = require('../controllers/libController'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.get('/lib', libList); +router.route('/lib-mixed').post(libList, plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/local.js b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js new file mode 100644 index 000000000..2d9c0ccd3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js @@ -0,0 +1,6 @@ +const express = require('express'); +const catchAsync = require('../utils/catchAsync'); +const router = express.Router(); +const localList = catchAsync(async (req, res) => { res.json([]); }); +router.get('/local', localList); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/other.js b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js new file mode 100644 index 000000000..59464624c --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js @@ -0,0 +1,3 @@ +// an unrelated const of the same name: it is not the handler registered in admin.js +const listAdmins = ['root']; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js new file mode 100644 index 000000000..7d13ff4c0 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js @@ -0,0 +1,7 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router.route('/admins-q').get(auth(), adminController.listAdmins); +const limit = adminController.adminCount; +module.exports = { router, limit }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js new file mode 100644 index 000000000..b75ba9086 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { listReports } = require('../controllers/report.controller'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.route('/reports').get(listReports); +router.route('/both').get(plainAdmins).post(plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js new file mode 100644 index 000000000..fb6ae185f --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js @@ -0,0 +1,3 @@ +// a service function of the same name: its own export is not a reader of the controller's wrapped handler +const listAdmins = async () => []; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js new file mode 100644 index 000000000..7ca747ca3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js @@ -0,0 +1,2 @@ +const catchAsync = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +module.exports = catchAsync; From a7274096e16e090e22431ea78fe0d19d53ab3685 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:23:21 -0700 Subject: [PATCH 26/37] javascript: only a function handed in a handler position is registered at a route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The const rule labelled any const read on a route line by the module making the call: the router receiving `router.get(…)`, the app receiving `app.use(…)`, a schema passed to `validate(bodySchema)`, `__dirname` inside `express.static(…)`, and TypeBox schemas inside a route's options object all read "registered as a route". A const is now registered only when both hold: - it is written in a handler position of the route call: a top-level argument, or an element of an array argument, bare or at the end of a member chain. Not the receiver, not inside an options object, not an argument of a nested call (`route_arg`, read from the call site's own argument list); - it holds a function: a function, an alias of a declared function, or a call that returns one (a callee the engine says returns a function, or a wrapper handed a function, nested wrappers included). A schema builder, a plain object, `express.Router()`, `require(…)`, a string or a number never does, so a router mounted with `app.use('/p', router)` stays a mount (`callable_const`). [registered] still needs the route line to hand over the function the const holds; an alias and a declaration whose wrapper call starts on the next line now reach it too. Both backends; TypeScript cases added. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/ax_registration.py | 163 ++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-impact | 11 +- .../skills/axiomcode/scripts/dl/impact.dl | 42 +++-- .../skills/axiomcode/scripts/graph_sql.py | 40 +++-- .../wrapped-handler-route/case.json | 43 ++++- .../wrapped-handler-route/src/app.js | 9 + .../src/middlewares/validate.js | 2 + .../src/routes/validated.js | 22 +++ .../wrapped-handler-route/case.json | 33 ++++ .../wrapped-handler-route/src/app.ts | 4 + .../src/controllers/userController.ts | 5 + .../wrapped-handler-route/src/routes/tasks.ts | 20 +++ .../wrapped-handler-route/src/routes/users.ts | 7 + .../wrapped-handler-route/src/schemas/task.ts | 4 + .../wrapped-handler-route/src/server.ts | 4 + .../src/utils/catchAsync.ts | 2 + 16 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 tests/cases/javascript/wrapped-handler-route/src/app.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js create mode 100644 tests/cases/javascript/wrapped-handler-route/src/routes/validated.js create mode 100644 tests/cases/typescript/wrapped-handler-route/case.json create mode 100644 tests/cases/typescript/wrapped-handler-route/src/app.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/routes/users.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/server.ts create mode 100644 tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 7f896a0dd..028c113df 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -116,30 +116,157 @@ def registrations(q, site_file=None): # A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a -# field, not a method, so it has no call edge of its own to carry the route wording. The three facts below are what -# both backends join instead (`const_route` in dl/impact.dl, graph_sql.direct_for_field): - -def route_calls(q, site_file=None): - """{(caller, file, line)}: the callable making a route-shaped call (`get`, `route`, `use` …), on every line the call - spans. A name read on a route line by THAT callable is an argument of the registration; one read inside an inline - handler on the same line (`router.get('/n', (req, res) => res.json(count))`) belongs to the handler, and is a read.""" - import ax_edges +# field, not a method, so it has no call edge of its own to carry the route wording. What both backends join instead +# (`const_route` in dl/impact.dl, graph_sql.direct_for_field) is two facts read from the source at the call sites the +# engine recorded, and neither is "the name appears on a route line": +# +# route_args the name is written IN A HANDLER POSITION of the route call: a top-level argument after the path +# (or an element of an array argument, which a router flattens) that is a bare name or a member +# chain ending in it. The receiver (`router.get`), an options object (`{ schema: { 200: S } }`), an +# argument of a nested call (`validate(bodySchema)`, `express.static(dir)`) and the path are not +# handed to the router to call, and a name written there is read, not registered. +# const_values the const HOLDS A FUNCTION: its initializer is a function, a reference to a declared function, or +# a call that returns one — a callee the engine says returns a function (`returns_fn`), or a wrapper +# that is handed a function (`asyncHandler(async (req, res) => …)`, nested wrappers too). A plain +# object, a schema builder, `express.Router()`, `require(…)`, a string or a number never is: a router +# mounted with `app.use('/p', router)` is a mount, not a handler. + +_IDENT = r'[A-Za-z_$][\w$]*' +_CHAIN = re.compile(rf'({_IDENT})(?:\s*\??\.\s*({_IDENT}))*\s*$') +_OPEN, _CLOSE = '([{', ')]}' + + +def _match(text, i): + """the index just past the bracket group opening at text[i] (strings and comments are already blanked)""" + d = 0 + for j in range(i, len(text)): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: + d -= 1 + if d == 0: return j + 1 + return len(text) + + +def _split_args(text, lo, hi): + """[(start, end)] of the top-level comma-separated items in text[lo:hi]""" + out, d, s = [], 0, lo + for j in range(lo, hi): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: d -= 1 + elif c == ',' and d == 0: out.append((s, j)); s = j + 1 + if text[s:hi].strip(): out.append((s, hi)) + return out + + +def _fn_literal(t): + """`function …`, `async (a, b) => …`, `req => …`, `(req: Request): void => …`""" + t = t.lstrip() + if re.match(r'(async\s+)?function\b', t): return True + m = re.match(r'(async\s*)?', t); t = t[m.end():] + if re.match(rf'{_IDENT}\s*=>', t): return True + if t.startswith('('): + return bool(re.match(r'\s*(:[^=;{}]*)?=>', t[_match(t, 0):])) + return False + + +def route_args(q, code, site_file=None): + """{(caller, file, line, name)}: `name` written in a handler position of a route-shaped call made by `caller`.""" + if not _has(q, 'call_sites') or code is None: + return set() sf = site_file or (lambda x: x) out = set() - for c, name, fp, a, b in q("""SELECT caller_id, callee_name, file_path, start_line, end_line FROM call_sites - WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): - if name.split('.')[-1].lower() in ROUTE_VERB: - out.update((c, sf(fp), l) for l in ax_edges.site_lines(a, b)) + for c, name, fp, a, ac, b, bc in q("""SELECT caller_id, callee_name, file_path, start_line, start_column, end_line, end_column + FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): + if name.split('.')[-1].lower() not in ROUTE_VERB: continue + b = b or a + if b < a or b - a > MAX_SITE_SPAN: continue + f = sf(fp); L = code(f) or [] + if b > len(L): continue + text = '\n'.join(L[a - 1:b]); starts = [0] + for ln in L[a - 1:b - 1]: starts.append(starts[-1] + len(ln) + 1) + end = starts[-1] + (bc - 1 if bc else len(L[b - 1])) # end_column is one past the call's `)` + close = text.rfind(')', 0, end) + if close < 0: continue + d, j = 0, close # back to the `(` that opens THIS call's arguments + while j >= 0: + if text[j] in _CLOSE: d += 1 + elif text[j] in _OPEN: + d -= 1 + if d == 0: break + j -= 1 + if j < 0: continue + line_of = lambda k: a + sum(1 for s0 in starts[1:] if s0 <= k) + # every argument is looked at: the path is a string (blanked), and a path or prefix held in a const is not a + # function, which `const_values` decides. `.route('/p').post(a, b)` has no path in its own argument list + todo = _split_args(text, j + 1, close) + while todo: + lo, hi = todo.pop() + t = text[lo:hi]; st = t.strip() + if st.startswith('[') and st.endswith(']'): # an array of handlers: the router flattens it + k = lo + t.index('['); todo += _split_args(text, k + 1, lo + t.rindex(']')); continue + m = _CHAIN.match(st) + if not m or st.startswith(('...', 'new ')): continue + last = re.search(rf'({_IDENT})\s*$', st) + out.add((c, f, line_of(lo + t.rindex(last.group(1))), last.group(1))) return out -def init_call_lines(q, site_file=None): - """{(file, line)}: lines a call starts on, other than `require(…)`. A const declared on one holds what a call - returned; `const ctrl = require('./ctrl')` holds a module, which is read on a route line, not registered there.""" +def const_values(q, code, decls, site_file=None): + """For each (file, line, name) const declaration: ({(file, line)} that hold a function, {(file, line, wrapper)} the + client callable its initializer calls, {(file, line, name)} the function it is an alias of).""" + callable_, wrap, alias = set(), set(), set() + if not decls or code is None: + return callable_, wrap, alias sf = site_file or (lambda x: x) - return {(sf(fp), a) for name, fp, a in q("""SELECT callee_name, file_path, start_line FROM call_sites - WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL""") - if name.split('.')[-1] != 'require'} + fns = {n for (n,) in q("SELECT DISTINCT name FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL AND name NOT LIKE '<%'")} + rets = {w for w, _m in returned_functions(q)} + callee_at = {} + for fp, l, col, m in q("""SELECT s.file_path, s.start_line, s.start_column, e.callee_method_id FROM call_sites s JOIN call_edges e + ON e.call_site_id = s.id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL + AND s.file_path IS NOT NULL AND s.start_line > 0"""): + callee_at.setdefault((sf(fp), l, col), set()).add(m) + + def call_value(text, i, f, a, starts, depth=0): + """(callable?, wrappers) for the expression at text[i:]""" + m = re.compile(rf'\s*(await\s+|new\s+)?({_IDENT}(?:\s*\??\.\s*{_IDENT})*)\s*').match(text, i) + if not m or m.group(1): return False, set() + k = m.end(); chain = m.group(2); k0 = m.start(2) + ln = a + sum(1 for s0 in starts[1:] if s0 <= k0); col = k0 - starts[ln - a] + 1 + ws = callee_at.get((f, ln, col), set()) + if k >= len(text) or text[k] != '(': # a reference: an alias of a declared function + last = re.split(r'\s*\??\.\s*', chain)[-1] + return (last in fns and depth == 0 and not re.match(r'\s*[\[+\-*/%?`]', text[k:k + 2]), {('alias', last)}) + if chain.split('.')[-1].strip() in ('require', 'import'): return False, set() + ok = bool(ws & rets); groups = 0 + while k < len(text) and text[k] == '(' and groups < 3: # `wrap(fn)` and curried `wrap(opts)(fn)` + e = _match(text, k); groups += 1 + for lo, hi in _split_args(text, k + 1, e - 1): + t = text[lo:hi] + if _fn_literal(t): ok = True + elif depth < 3: + inner, _w = call_value(text, lo, f, a, starts, depth + 1) + if inner or (re.fullmatch(rf'\s*{_IDENT}(\s*\.\s*{_IDENT})*\s*', t) and re.split(r'\s*\.\s*', t.strip())[-1] in fns): ok = True + k = e + while k < len(text) and text[k] in ' \t': k += 1 + if k < len(text) and text[k] == '.': return False, set() # `express.Router().use(…)`: a method of the result + return ok, {('wrap', w) for w in ws} + + for f, l, n in decls: + L = code(f) or [] + if not (0 < l <= len(L)): continue + chunk = L[l - 1:l + 40]; text = '\n'.join(chunk); starts = [0] + for ln in chunk[:-1]: starts.append(starts[-1] + len(ln) + 1) + m = re.search(rf'(?])', text[:len(chunk[0])]) + if not m: continue + if _fn_literal(text[m.end():]): callable_.add((f, l)); continue + ok, how = call_value(text, m.end(), f, l, starts) + if ok: callable_.add((f, l)) + for kind, x in how: + if kind == 'wrap': wrap.add((f, l, x)) + elif ok: alias.add((f, l, x)) + return callable_, wrap, alias def returned_functions(q): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index dc9129448..3ee75e00c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -710,7 +710,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '36' # 36: handoff_at, route_call, init_call, 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 = '36' # 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 @@ -896,8 +896,13 @@ class Impact: """SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL AND s.file_path IS NOT NULL""") for l in ax_edges.site_lines(r['start_line'], r['end_line'])})) - W('route_call', sorted(ax_registration.route_calls(g.q, g.site_file))) - W('init_call', sorted(ax_registration.init_call_lines(g.q, g.site_file))) + # a name in a handler position of a route call, and the consts of those names that hold a function + rargs = ax_registration.route_args(g.q, self.code, g.site_file) + rnames = {n for *_x, n in rargs} + fcall, fwrap, falias = ax_registration.const_values(g.q, self.code, sorted({(f['file'], f['line'], f['name']) for f in self.fields.values() + if f['name'] in rnames and f.get('file') and f.get('line')}), g.site_file) + W('route_arg', sorted(rargs)) + W('callable_const', sorted(fcall)); W('init_wrapper', sorted(fwrap)); W('init_alias', sorted(falias)) W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 9326ec00a..60036a945 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -430,31 +430,39 @@ frole("qualified", "uses", "writes/reads it"). frole("bare", "reads", "reads it" // A CONST THAT HOLDS A WRAPPED HANDLER is registered where it is handed to a route, not read there. // `const listAdmins = catchAsync(async (req, res) => …)` then `router.get('/admins', listAdmins)`: the declaration is // a const, not a method, so the method-target route wording (`route_site` above) never applied and the answer said -// only "[by name] … reads it" at the registration. Both legs need the name read on a route line BY THE CALLABLE -// MAKING THE ROUTE CALL (`route_call`): a read inside an inline handler on that line -// (`router.get('/n', (req, res) => res.json(count))`) is attributed to the handler, and stays a read. -// - registered: the declaration line calls a wrapper, the engine says what function that wrapper returns -// (`returns_fn`), and the route line hands THAT function over (`handoff_at`, on every line the call spans, since a -// chained registration records its edges on the statement's first line and names the handler on a later one). -// A hand-off of some other function on the same line (`.post(protect, admin, h)` hands `admin`) is not evidence. -// - by name: the wrapper is a library's, so nothing says what it returns — the const holds a call's result -// (`init_call`, not a `require`) and is an argument of the registration. The route's wording, the name's rung, -// as for a method named on a route line with no edge (`registered` above). -// Bare (`router.get('/a', listAdmins)`) or qualified through the module (`.post(auth(), userController.createUser)`). -.decl route_call(c:symbol, f:symbol, l:number) .input route_call -.decl init_call(f:symbol, l:number) .input init_call +// only "[by name] … reads it" at the registration. Two conditions, both required, both read by ax_registration: +// - `route_arg(c, f, l, n)`: the name is written IN A HANDLER POSITION of a route call `c` makes — a top-level +// argument or an element of an array argument, bare or at the end of a member chain. Not the receiver +// (`router.get`), not inside an options object (`{ schema: { 200: TaskSchema } }`), not an argument of a nested +// call (`validate(bodySchema)`), and not inside an inline handler, whose reads are the handler's. +// - `callable_const(ff, fll)`: the const HOLDS A FUNCTION — a function, a reference to a declared one, or a call that +// returns one (a callee the engine says returns a function, or a wrapper handed a function). A schema, an options +// object, `express.Router()` (a router mounted with `app.use('/p', router)` is a mount) or a module never does. +// Then: +// - registered: the function the const holds is known — the wrapper's returned function (`init_wrapper`, +// `returns_fn`) or the function it aliases (`init_alias`) — and the route line hands THAT function over +// (`handoff_at`). A hand-off of some other function on the same line (`.post(protect, admin, h)`) is not evidence. +// - by name: otherwise (a library wrapper says nothing about what it returns). The route's wording, the name's rung. +.decl route_arg(c:symbol, f:symbol, l:number, n:symbol) .input route_arg +.decl callable_const(f:symbol, l:number) .input callable_const +.decl init_wrapper(f:symbol, l:number, w:symbol) .input init_wrapper +.decl init_alias(f:symbol, l:number, n:symbol) .input init_alias .decl returns_fn(w:symbol, m:symbol) .input returns_fn +.decl const_holds(ff:symbol, fll:number, m:symbol) +const_holds(ff, fll, m) :- init_wrapper(ff, fll, w), returns_fn(w, m). +const_holds(ff, fll, m) :- init_alias(ff, fll, n), named(n, m). +.decl const_handed(q:symbol, c:symbol, f:symbol, l:number, ff:symbol, fll:number) +const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), + fref(q, c, _, f, l), route_site(f, l), route_arg(c, f, l, n). .decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) -const_route_edge(q, c, m, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), calls(_, w, _, ff, fll), returns_fn(w, m), - fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), handoff_at(c, m, f, l). +const_route_edge(q, c, m, f, l) :- const_handed(q, c, f, l, ff, fll), const_holds(ff, fll, m), handoff_at(c, m, f, l). .decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) const_route(q, c, cert, f, l) :- const_route_edge(q, c, _, f, l), cert_tier("callback_registered", cert, _). direct(q, c, "uses", why, cert, f, l) :- const_route(q, c, cert, f, l), registration(_, f, l, "route", _, why). // the row claims the hand-off edge on that line, so `verified:` looks it up like any registered row direct_edge(q, c, m) :- const_route_edge(q, c, m, _, _). .decl const_route_byname(q:symbol, c:symbol, f:symbol, l:number) -const_route_byname(q, c, f, l) :- target(q, "field", fl, _), field(fl, _, _, ff, fll), init_call(ff, fll), - fref(q, c, _, f, l), route_site(f, l), route_call(c, f, l), !const_route(q, c, _, f, l). +const_route_byname(q, c, f, l) :- const_handed(q, c, f, l, _, _), !const_route(q, c, _, f, l). direct(q, c, "uses", why, "by name", f, l) :- const_route_byname(q, c, f, l), registration(_, f, l, "route", _, why). .decl const_routed(q:symbol, c:symbol, f:symbol, l:number) const_routed(q, c, f, l) :- const_route(q, c, _, f, l). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index decfcc0ae..70a75e3fe 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1939,27 +1939,29 @@ def direct_for_field(q, fids, at, code, lines, inside, rel): for f_ in fids: r_ = field_rec(q, f_) if r_ and r_[1]: decl_sites.setdefault(r_[1], set()).add((r_[2], r_[3])) - # const_route / const_route_byname in dl/impact.dl: a const read on a route line by the callable making the route - # call. Registered when its declaration line calls a wrapper whose returned function (`returns_fn`) that line hands - # over; by name when the declaration holds some other call's result. Like the rules, keyed on the QUERY: any target - # declaration qualifies the site. - route_why, rcall, handed_to, ret_of, dcalls, inits = {}, set(), collections.defaultdict(set), collections.defaultdict(set), set(), set() + # const_handed / const_route / const_route_byname in dl/impact.dl: a const written in a HANDLER POSITION of a route + # call (`route_arg`) whose declaration holds a function (`callable_const`). Registered when the function it holds + # (`init_wrapper` + `returns_fn`, or `init_alias`) is what that line hands over; by name otherwise. Like the rules, + # keyed on the QUERY: any target declaration qualifies the site. + route_why, rargs, handed_to, holds, callable_names = {}, set(), collections.defaultdict(set), set(), set() if decl_sites: - inits = ax_registration.init_call_lines(q, rel) - dsites = {s for ss in decl_sites.values() for s in ss} - if dsites & inits: # init_call(ff,fll) on ANY target declaration + rargs = ax_registration.route_args(q, code, rel) + decls = sorted({(f_, l_, n_) for n_, ss in decl_sites.items() if n_ in {x[3] for x in rargs} for f_, l_ in ss}) + fcall, fwrap, falias = ax_registration.const_values(q, code, decls, rel) + callable_names = {n_ for f_, l_, n_ in decls if (f_, l_) in fcall} + if callable_names: for _d, rf_, rl_, k_, _key, w_ in ax_registration.registrations(q, rel): if k_ == 'route': route_why[(rf_, rl_)] = w_ - if route_why: - rcall = ax_registration.route_calls(q, rel) - for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) - for w_, fp_, l_ in q("""SELECT 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_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): - if (rel(fp_) if fp_ else '', l_ or 0) in dsites: dcalls.update(ret_of.get(w_, ())) # calls(_,w,_,ff,fll), returns_fn(w,m) - for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id - WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): - if m_ in dcalls: - for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at + ret_of = collections.defaultdict(set) + for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) + for f_, l_, w_ in fwrap: + if (f_, l_) in fcall: holds.update(ret_of.get(w_, ())) # const_holds :- init_wrapper, returns_fn + for f_, l_, an_ in falias: # const_holds :- init_alias, named + if (f_, l_) in fcall: holds.update(m_ for (m_,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL", an_)) + for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if m_ in holds: + for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at reg_cert = ax_edges.direct_cert('callback_registered') for fid in fids: rec = field_rec(q, fid) @@ -2015,7 +2017,7 @@ def declares_of(s_): own_decl |= {r[0] for r in q("SELECT file FROM symbols WHERE name = ? AND method_id IS NOT NULL AND file IS NOT NULL", n) if r[0] != ff} for c, rk, rf, rl, _e in fref: s_ = owner_of.get(c) - if (rf, rl) in route_why and (c, rf, rl) in rcall: # const_route*: bare or qualified + if (rf, rl) in route_why and n in callable_names and (c, rf, rl, n) in rargs: # const_handed: bare or qualified if handed_to.get((c, rf, rl)): rows.append((c, 'uses', route_why[(rf, rl)], reg_cert, rf, rl)) de += [(c, m_) for m_ in handed_to[(c, rf, rl)]] # direct_edge(q,c,m) :- const_route_edge diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json index 0fd3ee3d2..e4c85a01f 100644 --- a/tests/cases/javascript/wrapped-handler-route/case.json +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -62,7 +62,7 @@ "avoid": ["src/routes/lib.js:5 — reads it", "[registered]"]}, {"why": "CONTROL: a line that also hands over a plain function (`.post(libList, plainAdmins)`) is not evidence for the const: the hand-off is plainAdmins', so the const's row stays [by name]", "run": ["impact", "src/controllers/libController.js:2"], - "want": ["[by name] lib. src/routes/lib.js:6 — registered as a POST route \"/lib-mixed\""], + "want": ["registered as a POST route \"/lib-mixed\""], "avoid": ["[registered] lib."]}, {"why": "CONTROL: a const holding a module (`const adminController = require(…)`) read on a route line is read there, not registered", "run": ["impact", "src/routes/chained.js:2"], @@ -70,5 +70,44 @@ {"why": "CONTROL: a qualified read of a const holding a value, off any route line, stays a read", "run": ["impact", "adminCount"], "want": ["src/routes/qualified.js:6 — writes/reads it"], - "avoid": ["registered as a"]} + "avoid": ["registered as a"]}, + {"why": "a const passed to a validation middleware on a route line (`validate(bodySchema)`) is an argument of that call, not a handler the router calls; and a schema built by a library (`Joi.object({…})`) is not a function even where it sits in a handler position (`.post('/schema-direct', bodySchema, h)`)", + "run": ["impact", "bodySchema"], + "want": ["[in scope] validated. src/routes/validated.js:8 — reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/opts', routeOptions, h)`) is not callable: read, not registered", + "run": ["impact", "routeOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a path prefix held in a const (`router.use(PREFIX, h)`) is a string, not a handler", + "run": ["impact", "PREFIX"], + "avoid": ["registered as a"]}, + {"why": "a router mounted on another (`apiRouter.use('/admin', adminRoutes)`, `app.use('/api', apiRouter)`) is a mount, and the receiver of a route call is not its handler: `express.Router()` returns no function the graph knows of", + "run": ["impact", "apiRouter"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the app itself, the receiver of every `app.use(…)`, is read there, not registered", + "run": ["impact", "src/app.js:3"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a const passed into a nested call on a route line (`express.static(publicDir)`) is that call's argument", + "run": ["impact", "publicDir"], + "avoid": ["registered as a"]}, + {"why": "a router module's own router const, the receiver of each route line (`router.get('/admins', listAdmins)`), is read on those lines, not registered on them, here or in the other files that declare a `router` of their own", + "run": ["impact", "src/routes/admin.js:4"], + "want": ["[in scope] admin. src/routes/admin.js:5 — reads it"], + "avoid": ["registered as a"]}, + {"why": "a wrapped handler handed over inside an array of handlers (`.get('/array', [validate(s), ctrl.listAdmins])`) is registered: a router flattens the array", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["registered as a GET route \"/array\""]}, + {"why": "a const that aliases a declared function (`const aliasOf = ctrl.plainAdmins`) holds that function, and the route line hands it over", + "run": ["impact", "aliasOf"], + "want": ["[registered] validated. src/routes/validated.js:17 — registered as a GET route \"/aliased\""]}, + {"why": "a declaration whose wrapper call starts on the next line (`const multiLine =` then `catchAsync(…)`) holds the wrapped function all the same", + "run": ["impact", "multiLine"], + "want": ["[registered] validated. src/routes/validated.js:18 — registered as a GET route \"/multi-line\""]}, + {"why": "nested wrappers (`catchAsync(validate(async (req, res) => …))`) still hold a function", + "run": ["impact", "nested"], + "want": ["registered as a GET route \"/nested\""], + "avoid": ["reads it"]} ]} diff --git a/tests/cases/javascript/wrapped-handler-route/src/app.js b/tests/cases/javascript/wrapped-handler-route/src/app.js new file mode 100644 index 000000000..0a387d7d9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/app.js @@ -0,0 +1,9 @@ +const express = require('express'); +const adminRoutes = require('./routes/admin'); +const app = express(); +const apiRouter = express.Router(); +const publicDir = __dirname + '/public'; +apiRouter.use('/admin', adminRoutes); +app.use('/api', apiRouter); +app.use('/static', express.static(publicDir)); +module.exports = app; diff --git a/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js new file mode 100644 index 000000000..1515f492b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js @@ -0,0 +1,2 @@ +const validate = (schema) => (req, res, next) => (schema ? next() : next()); +module.exports = validate; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js new file mode 100644 index 000000000..38e1daf95 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js @@ -0,0 +1,22 @@ +const express = require('express'); +const Joi = require('joi'); +const validate = require('../middlewares/validate'); +const catchAsync = require('../utils/catchAsync'); +const adminController = require('../controllers/adminController'); +const router = express.Router(); +const bodySchema = Joi.object({ name: Joi.string().custom((v) => v) }); +const routeOptions = { schema: { body: bodySchema } }; +const PREFIX = '/v'; +const aliasOf = adminController.plainAdmins; +const multiLine = + catchAsync(async (req, res) => { res.json([]); }); +const nested = catchAsync(validate(async (req, res) => { res.json([]); })); +router.post('/validated', validate(bodySchema), adminController.listAdmins); +router.post('/opts', routeOptions, adminController.plainAdmins); +router.post('/schema-direct', bodySchema, adminController.plainAdmins); +router.get('/aliased', aliasOf); +router.get('/multi-line', multiLine); +router.get('/nested', nested); +router.get('/array', [validate(bodySchema), adminController.listAdmins]); +router.use(PREFIX, adminController.plainAdmins); +module.exports = router; diff --git a/tests/cases/typescript/wrapped-handler-route/case.json b/tests/cases/typescript/wrapped-handler-route/case.json new file mode 100644 index 000000000..73fff518d --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/case.json @@ -0,0 +1,33 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a wrapped handler const (`const listUsers = catchAsync(async (req, res) => …)`) handed to a route is registered there, in TypeScript as in JavaScript. The TypeScript graph records no returned-function fact for the wrapper, so the row keeps the name's rung, as for a library wrapper; it was '[by name] … reads it'", + "run": ["impact", "listUsers"], + "want": ["[by name] users. src/routes/users.ts:4 — registered as a GET route \"/users\"", "registered as a POST route \"/users\""], + "avoid": ["src/routes/users.ts:4 — reads it"]}, + {"why": "CONTROL: a function handler passed directly keeps its route row from the method rule", + "run": ["impact", "getUser"], + "want": ["registered as a GET route \"/users/:id\""]}, + {"why": "a schema built by a library, written inside a route's options object (`{ schema: { response: { 200: TaskSchema } } }`), is part of the options, not a handler the router calls: read, never registered", + "run": ["impact", "TaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the same for a schema on an options object split over several lines", + "run": ["impact", "UpdateTaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/users', userOptions, listUsers)`) is not callable", + "run": ["impact", "userOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the router a module registers routes on is their receiver, not a handler of them", + "run": ["impact", "src/routes/users.ts:3"], + "avoid": ["registered as a"]}, + {"why": "a router mounted with `app.use('/api', api)` is a mount, and the app is the receiver: neither is registered as a route", + "run": ["impact", "src/app.ts:4"], + "want": ["[by name] server. src/server.ts:3 — reads it"], + "avoid": ["registered as a"]}, + {"why": "…nor the app", + "run": ["impact", "src/app.ts:3"], + "want": ["reads it"], + "avoid": ["registered as a"]} + ]} diff --git a/tests/cases/typescript/wrapped-handler-route/src/app.ts b/tests/cases/typescript/wrapped-handler-route/src/app.ts new file mode 100644 index 000000000..78f0e3830 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/app.ts @@ -0,0 +1,4 @@ +import express from 'express'; +import usersRouter from './routes/users'; +export const app = express(); +export const api = express.Router(); diff --git a/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts new file mode 100644 index 000000000..216a01342 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express'; +import { catchAsync } from '../utils/catchAsync'; +export const listUsers = catchAsync(async (req: Request, res: Response) => { res.json([]); }); +export async function getUser(req: Request, res: Response) { res.json({}); } +export const userOptions = { strict: true }; diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts new file mode 100644 index 000000000..8f4274dfc --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { TaskSchema, CreateTaskSchema, UpdateTaskSchema } from '../schemas/task'; +export default async function plugin(fastify: FastifyInstance) { + fastify.get('/:id', { schema: { response: { 200: TaskSchema } } }, async function getTask(request: any) { + return { id: request.params.id }; + }); + fastify.post('/', { schema: { body: CreateTaskSchema, response: { 201: TaskSchema } } }, async (request: any) => request.body); + fastify.patch( + '/:id', + { + schema: { + body: UpdateTaskSchema, + response: { 200: TaskSchema } + } + }, + async function updateTask(request: any) { + return request.body; + } + ); +} diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts new file mode 100644 index 000000000..785505488 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts @@ -0,0 +1,7 @@ +import express from 'express'; +import { listUsers, getUser, userOptions } from '../controllers/userController'; +const router = express.Router(); +router.get('/users', listUsers); +router.get('/users/:id', getUser); +router.post('/users', userOptions, listUsers); +export default router; diff --git a/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts new file mode 100644 index 000000000..0696333f5 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts @@ -0,0 +1,4 @@ +import { Type } from '@sinclair/typebox'; +export const TaskSchema = Type.Object({ id: Type.String(), name: Type.String() }); +export const CreateTaskSchema = Type.Object({ name: Type.String() }); +export const UpdateTaskSchema = Type.Partial(CreateTaskSchema); diff --git a/tests/cases/typescript/wrapped-handler-route/src/server.ts b/tests/cases/typescript/wrapped-handler-route/src/server.ts new file mode 100644 index 000000000..1af292190 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/server.ts @@ -0,0 +1,4 @@ +import usersRouter from './routes/users'; +import { app, api } from './app'; +api.use('/v1', usersRouter); +app.use('/api', api); diff --git a/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts new file mode 100644 index 000000000..027203028 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts @@ -0,0 +1,2 @@ +export type Handler = (req: any, res: any, next: any) => Promise | void; +export const catchAsync = (fn: Handler): Handler => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); From ee5900d11ea70d2e3e69ed96ab7ed58b45964dba Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:39:33 -0700 Subject: [PATCH 27/37] typescript: a || / ?? / && default carries the callback's call signature const onError = options.onError || defaultOnError; onError(m) left the variable with no call signature, so every call through it was unresolved, while the same binding without the default resolved. The value's call signature now flows from the operand the compiler names: the left one of || and ??, the right one of &&. Only the shape flows. Not expr_type, so (xs || []).map keeps its receiver type and does not also take the empty array literal's. Not the other operand, so when both operands carry a call signature the site does not gain a second candidate the compiler never picks. Case 78: four missed sites now agree with the compiler (5/5); the undefaulted binding and the || [] receiver are controls and stay as they were. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/options.ts | 54 +++++++++++++++++++ .../src/tsconfig.json | 9 ++++ .../78-logical-default-callback.edges | 9 ++++ .../78-logical-default-callback.entries | 9 ++++ .../78-logical-default-callback.envelope | 5 ++ .../78-logical-default-callback.fields | 6 +++ .../78-logical-default-callback.fields-oracle | 7 +++ .../78-logical-default-callback.oracle | 1 + .../78-logical-default-callback.type-use | 3 ++ .../78-logical-default-callback.types-oracle | 7 +++ .../engine/expression-resolution/expr-type.dl | 20 +++++++ graph/typescript/souffle/decls_all.dl | 1 + 12 files changed, 131 insertions(+) create mode 100644 graph/test/typescript/cases/78-logical-default-callback/src/options.ts create mode 100644 graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json create mode 100644 graph/test/typescript/expected/78-logical-default-callback.edges create mode 100644 graph/test/typescript/expected/78-logical-default-callback.entries create mode 100644 graph/test/typescript/expected/78-logical-default-callback.envelope create mode 100644 graph/test/typescript/expected/78-logical-default-callback.fields create mode 100644 graph/test/typescript/expected/78-logical-default-callback.fields-oracle create mode 100644 graph/test/typescript/expected/78-logical-default-callback.oracle create mode 100644 graph/test/typescript/expected/78-logical-default-callback.type-use create mode 100644 graph/test/typescript/expected/78-logical-default-callback.types-oracle diff --git a/graph/test/typescript/cases/78-logical-default-callback/src/options.ts b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts new file mode 100644 index 000000000..c74e25429 --- /dev/null +++ b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts @@ -0,0 +1,54 @@ +// AN OPTIONAL CALLBACK WITH A DEFAULT. `const onError = options.onError || defaultOnError` is how a +// TypeScript API takes an optional callback: the property if the caller supplied one, a module default +// otherwise. The variable is typed by the compiler as the property's function type, so a call through it +// names that type's call signature, exactly as the undefaulted binding (`plain`) does. +export type ErrorHandler = (message: string) => void +export type Formatter = (value: number, digits: number) => string + +export interface Options { + onError?: ErrorHandler + format?: Formatter + tags?: string[] +} + +export function defaultOnError(message: string): void { + console.error(message) +} + +const defaultFormat = (value: number, digits: number): string => value.toFixed(digits) + +export function withOr(options: Options): void { + const onError = options.onError || defaultOnError + onError('or') +} + +export function withNullish(options: Options): string { + const format = options.format ?? defaultFormat + return format(1, 2) +} + +// A reassigned `let` keeps the same shape: the value is still one operand or the other. +export function withLet(options: Options): void { + let report = options.onError ?? defaultOnError + report('let') +} + +// CONTROL: the identical binding with no default. Already resolved; must not change. +export function plain(options: Options): void { + const onError = options.onError + if (onError) onError('plain') +} + +// CONTROL: an array defaulted with `|| []`, then mapped. The receiver stays the property's array type; +// the default must not add the empty array literal's type to it. +export function tagList(options: Options): string[] { + const tags = options.tags || [] + return tags.map((t) => t.toUpperCase()) +} + +// CONTROL: `&&` yields its RIGHT operand, so a guarded handler keeps the handler's signature and a +// boolean left operand contributes nothing. +export function guarded(enabled: boolean, options: Options): void { + const handler = enabled && options.onError + if (handler) handler('guarded') +} diff --git a/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json b/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/expected/78-logical-default-callback.edges b/graph/test/typescript/expected/78-logical-default-callback.edges new file mode 100644 index 000000000..73865b434 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.edges @@ -0,0 +1,9 @@ +ambiguous_unknown METHOD_CALL options#(?) @L46 -> - +ambiguous_unknown METHOD_CALL options#defaultFormat(number,number) @L18 -> - +ambiguous_unknown METHOD_CALL options#defaultOnError(string) @L15 -> - +ambiguous_unknown METHOD_CALL options#tagList(Options) @L46 -> - +known_edge FUNCTION_CALL options#guarded(boolean,Options) @L53 -> options#(string) +known_edge FUNCTION_CALL options#plain(Options) @L39 -> options#(string) +known_edge FUNCTION_CALL options#withLet(Options) @L33 -> options#(string) +known_edge FUNCTION_CALL options#withNullish(Options) @L27 -> options#(number,number) +known_edge FUNCTION_CALL options#withOr(Options) @L22 -> options#(string) diff --git a/graph/test/typescript/expected/78-logical-default-callback.entries b/graph/test/typescript/expected/78-logical-default-callback.entries new file mode 100644 index 000000000..531df4716 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.entries @@ -0,0 +1,9 @@ +── entry_point (8) ── + exported_from_entry_module options#defaultOnError options.ts:14 + exported_from_entry_module options#guarded options.ts:51 + exported_from_entry_module options#plain options.ts:37 + exported_from_entry_module options#tagList options.ts:44 + exported_from_entry_module options#withLet options.ts:31 + exported_from_entry_module options#withNullish options.ts:25 + exported_from_entry_module options#withOr options.ts:20 + unimported_module options# options.ts:1 diff --git a/graph/test/typescript/expected/78-logical-default-callback.envelope b/graph/test/typescript/expected/78-logical-default-callback.envelope new file mode 100644 index 000000000..8021f74a5 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.envelope @@ -0,0 +1,5 @@ +value options#@5:28 -> options#defaultOnError +value options#@6:25 -> options#@18 +value_call options#withLet -> options#defaultOnError +value_call options#withNullish -> options#@18 +value_call options#withOr -> options#defaultOnError diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields b/graph/test/typescript/expected/78-logical-default-callback.fields new file mode 100644 index 000000000..a3fc4b698 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.fields @@ -0,0 +1,6 @@ +known_edge read options#guarded(boolean,Options) -> Options#onError +known_edge read options#plain(Options) -> Options#onError +known_edge read options#tagList(Options) -> Options#tags +known_edge read options#withLet(Options) -> Options#onError +known_edge read options#withNullish(Options) -> Options#format +known_edge read options#withOr(Options) -> Options#onError diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields-oracle b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle new file mode 100644 index 000000000..561eba1c8 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle @@ -0,0 +1,7 @@ +78-logical-default-callback [fields] + precision 1.0000 (6 correct, 0 wrong) + recall 1.0000 (6 of 6 the compiler resolved) + sites 6 resolved 6 (100.0%) + tiers known_edge=6 + access read=6 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-logical-default-callback.oracle b/graph/test/typescript/expected/78-logical-default-callback.oracle new file mode 100644 index 000000000..6c4bb5cbb --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.oracle @@ -0,0 +1 @@ +oracle=5 engine=5 agree=5 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-logical-default-callback.type-use b/graph/test/typescript/expected/78-logical-default-callback.type-use new file mode 100644 index 000000000..d5a833534 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.type-use @@ -0,0 +1,3 @@ +known_edge FIELD_TYPE 0 Options [FIELD] -> ErrorHandler +known_edge FIELD_TYPE 0 Options [FIELD] -> Formatter +known_edge METHOD_PARAM 0 options [METHOD_PARAM] -> Options diff --git a/graph/test/typescript/expected/78-logical-default-callback.types-oracle b/graph/test/typescript/expected/78-logical-default-callback.types-oracle new file mode 100644 index 000000000..8e27d59c3 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.types-oracle @@ -0,0 +1,7 @@ +78-logical-default-callback [types] + precision 1.0000 (3 correct, 0 wrong) + recall 1.0000 (3 of 3 the compiler resolved) + sites 8 resolved 8 (100.0%) + tiers known_edge=8 + contexts FIELD_TYPE=2 METHOD_PARAM=6 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index 670004b80..f7251ef11 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -280,6 +280,26 @@ expr_type(e, prov, t) :- expr_kind("client", "TERNARY_EXPRESSION", _, e), edge_role_is_ternary_branch("TERNARY_THEN"). edge_role_is_ternary_branch("TERNARY_ELSE"). +// ── a `||` / `??` / `&&` DEFAULT — the call signature of the operand the compiler names ── +// `const onError = options.onError || defaultOnError; onError(m)` is how a TypeScript API +// takes an optional callback. The compiler types the call by the LEFT operand's function +// type (the property's), and by the RIGHT one for `flag && handler`; the other operand is a +// value that may RUN, which is value-flow.dl's question (value_branch), not the call site's. +// So only that operand's SHAPE flows, and nothing else: +// - not expr_type: typing `opts.tags || []` as the empty array literal too sends `.map` +// to ReadonlyArray's declaration where the compiler names Array's; +// - not the other operand: when both carry a call signature (a property holding a +// function, defaulted to an object with its own call signature) the compiler still +// resolves to the left one, and a second candidate is committed to wrongly downstream. +expr_shape(e, s) :- expr_kind("client", "BINARY_EXPRESSION", _, e), + expr_operator("client", op, e), + binary_op_names_operand(op, r), + expr_child("client", e, r, _, side), + expr_shape(side, s). +binary_op_names_operand("||", "LEFT_OPERAND"). +binary_op_names_operand("??", "LEFT_OPERAND"). +binary_op_names_operand("&&", "RIGHT_OPERAND"). + // ── an ARROW / FUNCTION expression — the value IS the function ────────────── // `anonymousDeclarationHash` gives the ts_method the expression introduces. Java // matches lambdas to their bodies by source POSITION; here it is a column, so an diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index e5ffef4be..e3527147e 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -196,6 +196,7 @@ .decl compound_op_keeps_value(c0:symbol) .decl value_branch(c0:symbol,c1:symbol) .decl binary_op_yields_operand(c0:symbol,c1:symbol) +.decl binary_op_names_operand(c0:symbol,c1:symbol) .decl holder_holds_function(c0:symbol,c1:symbol) .decl call_runs_client_body(c0:symbol) .decl hof_boundary_site(c0:symbol) From d80593258d69bf11352357d0459bd51cbd51137f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:41:11 -0700 Subject: [PATCH 28/37] typescript: case 78 pins the default whose both operands carry a call signature const report = sink.report || sink, where sink is itself callable: the compiler names the left operand's signature, and the engine now does too, with no second candidate from the right one (oracle 6/6, extra 0). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../78-logical-default-callback/src/options.ts | 14 ++++++++++++++ .../expected/78-logical-default-callback.edges | 2 ++ .../expected/78-logical-default-callback.entries | 3 ++- .../expected/78-logical-default-callback.fields | 1 + .../78-logical-default-callback.fields-oracle | 10 +++++----- .../expected/78-logical-default-callback.oracle | 2 +- .../expected/78-logical-default-callback.type-use | 2 ++ .../78-logical-default-callback.types-oracle | 10 +++++----- 8 files changed, 32 insertions(+), 12 deletions(-) diff --git a/graph/test/typescript/cases/78-logical-default-callback/src/options.ts b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts index c74e25429..5f5bde461 100644 --- a/graph/test/typescript/cases/78-logical-default-callback/src/options.ts +++ b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts @@ -52,3 +52,17 @@ export function guarded(enabled: boolean, options: Options): void { const handler = enabled && options.onError if (handler) handler('guarded') } + +// CONTROL: BOTH operands carry a call signature — a property holding a function, defaulted to the +// object that carries it, which is itself callable. The compiler resolves the call to the LEFT +// operand's signature; the right one must not become a second candidate. +export type Reporter = (code: number) => void +export interface Callable { + (code: number): void + report?: Reporter +} + +export function reportWith(sink: Callable): void { + const report = sink.report || sink + report(7) +} diff --git a/graph/test/typescript/expected/78-logical-default-callback.edges b/graph/test/typescript/expected/78-logical-default-callback.edges index 73865b434..3af8f46c6 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.edges +++ b/graph/test/typescript/expected/78-logical-default-callback.edges @@ -2,8 +2,10 @@ ambiguous_unknown METHOD_CALL options#(?) @L46 -> - ambiguous_unknown METHOD_CALL options#defaultFormat(number,number) @L18 -> - ambiguous_unknown METHOD_CALL options#defaultOnError(string) @L15 -> - ambiguous_unknown METHOD_CALL options#tagList(Options) @L46 -> - +callback_registered METHOD_CALL options#tagList(Options) @L46 -> options#(?) known_edge FUNCTION_CALL options#guarded(boolean,Options) @L53 -> options#(string) known_edge FUNCTION_CALL options#plain(Options) @L39 -> options#(string) +known_edge FUNCTION_CALL options#reportWith(Callable) @L67 -> options#(number) known_edge FUNCTION_CALL options#withLet(Options) @L33 -> options#(string) known_edge FUNCTION_CALL options#withNullish(Options) @L27 -> options#(number,number) known_edge FUNCTION_CALL options#withOr(Options) @L22 -> options#(string) diff --git a/graph/test/typescript/expected/78-logical-default-callback.entries b/graph/test/typescript/expected/78-logical-default-callback.entries index 531df4716..e2d98ae70 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.entries +++ b/graph/test/typescript/expected/78-logical-default-callback.entries @@ -1,7 +1,8 @@ -── entry_point (8) ── +── entry_point (9) ── exported_from_entry_module options#defaultOnError options.ts:14 exported_from_entry_module options#guarded options.ts:51 exported_from_entry_module options#plain options.ts:37 + exported_from_entry_module options#reportWith options.ts:65 exported_from_entry_module options#tagList options.ts:44 exported_from_entry_module options#withLet options.ts:31 exported_from_entry_module options#withNullish options.ts:25 diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields b/graph/test/typescript/expected/78-logical-default-callback.fields index a3fc4b698..3aaedddd2 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.fields +++ b/graph/test/typescript/expected/78-logical-default-callback.fields @@ -1,5 +1,6 @@ known_edge read options#guarded(boolean,Options) -> Options#onError known_edge read options#plain(Options) -> Options#onError +known_edge read options#reportWith(Callable) -> Callable#report known_edge read options#tagList(Options) -> Options#tags known_edge read options#withLet(Options) -> Options#onError known_edge read options#withNullish(Options) -> Options#format diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields-oracle b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle index 561eba1c8..49aa2be44 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.fields-oracle +++ b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle @@ -1,7 +1,7 @@ 78-logical-default-callback [fields] - precision 1.0000 (6 correct, 0 wrong) - recall 1.0000 (6 of 6 the compiler resolved) - sites 6 resolved 6 (100.0%) - tiers known_edge=6 - access read=6 + precision 1.0000 (7 correct, 0 wrong) + recall 1.0000 (7 of 7 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + access read=7 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-logical-default-callback.oracle b/graph/test/typescript/expected/78-logical-default-callback.oracle index 6c4bb5cbb..4794eb8a3 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.oracle +++ b/graph/test/typescript/expected/78-logical-default-callback.oracle @@ -1 +1 @@ -oracle=5 engine=5 agree=5 missing=0 (known 0, NEW 0) extra=0 +oracle=6 engine=6 agree=6 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-logical-default-callback.type-use b/graph/test/typescript/expected/78-logical-default-callback.type-use index d5a833534..874a49357 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.type-use +++ b/graph/test/typescript/expected/78-logical-default-callback.type-use @@ -1,3 +1,5 @@ +known_edge FIELD_TYPE 0 Callable [FIELD] -> Reporter known_edge FIELD_TYPE 0 Options [FIELD] -> ErrorHandler known_edge FIELD_TYPE 0 Options [FIELD] -> Formatter +known_edge METHOD_PARAM 0 options [METHOD_PARAM] -> Callable known_edge METHOD_PARAM 0 options [METHOD_PARAM] -> Options diff --git a/graph/test/typescript/expected/78-logical-default-callback.types-oracle b/graph/test/typescript/expected/78-logical-default-callback.types-oracle index 8e27d59c3..c99e933e4 100644 --- a/graph/test/typescript/expected/78-logical-default-callback.types-oracle +++ b/graph/test/typescript/expected/78-logical-default-callback.types-oracle @@ -1,7 +1,7 @@ 78-logical-default-callback [types] - precision 1.0000 (3 correct, 0 wrong) - recall 1.0000 (3 of 3 the compiler resolved) - sites 8 resolved 8 (100.0%) - tiers known_edge=8 - contexts FIELD_TYPE=2 METHOD_PARAM=6 + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 10 resolved 10 (100.0%) + tiers known_edge=10 + contexts FIELD_TYPE=3 METHOD_PARAM=7 not scored: 0 rows whose target is not a client declaration From 3608e8a5267dc68d2d23ec4f6746cec32c8e47d7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:16 -0700 Subject: [PATCH 29/37] javascript: a class field initializer spans its fields, not the class, so it defines none of the methods beside them The synthetic / row took the class's span, and the defines edges built from span nesting made every method of the class a callable written inside it: impact and path walked that hop. The row now spans its first to last running initializer, and a method written between two of them is kept a sibling by name. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../extractors/js-declaration-extractor.ts | 29 ++++++++++--------- .../skills/axiomcode/scripts/ax_edges.py | 24 ++++++++++++--- .../field-initializer-is-a-sibling/case.json | 21 ++++++++++++++ .../field-initializer-is-a-sibling/src/app.js | 29 +++++++++++++++++++ 4 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 tests/cases/javascript/field-initializer-is-a-sibling/case.json create mode 100644 tests/cases/javascript/field-initializer-is-a-sibling/src/app.js diff --git a/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts index 718bb4dd5..e1794d7e8 100644 --- a/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts @@ -800,8 +800,10 @@ export class JsDeclarationExtractor { * A declared constructor is NOT reused as the owner even though it is where the * initializer runs: a field written above the constructor would then be an expression * outside its owner's span, which is the containment invariant that catches context - * leaking down the traversal. The synthetic row spans the class, so it contains every - * initializer in it whatever the member order. + * leaking down the traversal. The synthetic row spans the fields it runs, from the first + * to the last, so it contains every initializer it owns whatever the member order. It + * does NOT span the class: a callable's span is what it defines, and the methods written + * beside the fields are its siblings (#1663). */ private attributeFieldInitializers(node: ts.ClassLikeDeclaration, context: WalkContext): void { if (context.ownerType === undefined) { @@ -812,27 +814,26 @@ export class JsDeclarationExtractor { if (first === undefined) { continue; } + const owned = node.members.filter((member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && member.initializer !== undefined + && !isCallableExpression(member.initializer) && initializerRunsCode(member.initializer) + && hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic); // One callable per class per staticness, hung off the same field the scope builder // opened the scope on, so the row's body scope is a scope that exists. - const owner = this.synthesizeInitializerOwner(node, first, context, isStatic); - for (const member of node.members) { - if (!ts.isPropertyDeclaration(member) || member.initializer === undefined - || isCallableExpression(member.initializer) || !initializerRunsCode(member.initializer)) { - continue; - } - if (hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic) { - this.fieldInitOwnerByNode.set(nodeKey(member), owner); - } + const owner = this.synthesizeInitializerOwner(first, owned[owned.length - 1] ?? first, context, isStatic); + for (const member of owned) { + this.fieldInitOwnerByNode.set(nodeKey(member), owner); } } } - /** The synthetic callable a class's field initializers run inside. */ + /** The synthetic callable a class's field initializers run inside, spanning `first` to `last`. */ private synthesizeInitializerOwner( - node: ts.ClassLikeDeclaration, first: ts.PropertyDeclaration, + first: ts.PropertyDeclaration, last: ts.PropertyDeclaration, context: WalkContext, isStatic: boolean, ): string { - const at = this.positionOf(node); + const start = this.positionOf(first); + const at = { ...start, endLine: this.positionOf(last).endLine }; const bodyScope = this.options.binder.scopeOpenedBy.get(nodeKey(first)) ?? context.scope; const ownerType = context.ownerType; const name = isStatic ? '' : ''; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 3ad1d58f1..be2127937 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -272,6 +272,20 @@ def anon_depth(display): return len(_ANON_SCOPE.findall(display or '')) +# A class's synthesized initializer spans the fields it runs, and a method written between two of them lies inside +# that span without being written inside any initializer (#1663): it is the initializer's sibling, and its display +# says so, the same owner and no scope of the initializer's in between. What an initializer does define (an arrow or +# a function expression handed to a call in it) has a display that is not the initializer's sibling. +_NAMED_INIT = re.compile(r'(?:^|\.)<(?:static-init|instance-init|clinit|classbody)>$') + + +def _sibling_of_init(display, init_display): + """`Type.m` beside `Type.`: a member of the initializer's owner, not a callable written inside it""" + if not init_display or not _NAMED_INIT.search(init_display) or not display or anon_depth(display): return False + owner = init_display.rsplit('.', 1)[0] if '.' in init_display else '' + return bool(owner) and display.rsplit('.', 1)[0] == owner and '.' in display + + def _within(inner, outer): """a call site's (line, col, end_line, end_col) lies inside another's and is not the same one""" return inner != outer and outer[:2] <= inner[:2] and inner[2:] <= outer[2:] @@ -294,10 +308,10 @@ def defines_edges(callables, sites=None): """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id[, qualified_name]). `sites(method_ids)` returns (caller_id, line, col, end_line, end_col) for the call sites of those callables; it is asked only about lines where more than one callable could have defined an anonymous one.""" - byfile = {}; mid_of = {}; lex = {} + byfile = {}; mid_of = {}; lex = {}; disp_of = {} for f, ln, en, i, disp, mid, *qn in callables: if not (ln and en) or str(mid or i).startswith('generated:'): continue - byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i + byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i; disp_of[i] = disp if qn and qn[0] and str(mid or i).startswith(_LEXICAL_IDS): lex[i] = qn[0] def may_hold(c, x): """on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's""" @@ -309,9 +323,11 @@ def may_hold(c, x): # pop what ends before this one ends (END against END, not against this one's start: the two agree on nested # spans and not on overlapping ones), and a sibling: the same span at the same depth while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d)): st.pop() - # past an equal span the name says it is not written in (kept on the stack: a later one may be) + # past an equal span the name says it is not written in (kept on the stack: a later one may be), and past a + # class's initializer when this is a member of that class beside it (#1663) j = len(st) - 1 - while j >= 0 and st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i): j -= 1 + while j >= 0 and (_sibling_of_init(disp_of[i], disp_of[st[j][3]]) + or (st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i))): j -= 1 if j >= 0 and st[j][3] != i: parent[f, i] = st[j][3] st.append((ln, -neg, d, i)) # equal spans holding an anonymous callable and more than one other candidate to have written it diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/case.json b/tests/cases/javascript/field-initializer-is-a-sibling/case.json new file mode 100644 index 000000000..00fe2dab2 --- /dev/null +++ b/tests/cases/javascript/field-initializer-is-a-sibling/case.json @@ -0,0 +1,21 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a static field initializer defines only what is written in it, not the methods of its class", + "run": ["path", "Registry.", "tick"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "nor does an instance field initializer", + "run": ["path", "Panel.", "tick"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "a method written between two initialized fields is their sibling, not written inside them", + "run": ["path", "Mixed.", "tick"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "control: an arrow handed to a call in the initializer is still reached from it, and a function inside a method is not", + "run": ["path", "Host.", "tick"], + "want": ["callback_registered", "", "tick"], + "avoid": ["defines", "helper"]}, + {"why": "control: a function declared inside a method is still defined by it", + "run": ["path", "Host.go", "tick"], + "want": ["Host.go", "helper", "tick"]}]} diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js new file mode 100644 index 000000000..563586d8c --- /dev/null +++ b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js @@ -0,0 +1,29 @@ +function boot() { return 1; } +function tick() { return 2; } +function wrap(f) { return f; } + +class Registry { + static count = boot(); + add(x) { return tick(); } + remove(x) { return tick(); } +} + +class Panel { + size = boot(); + open() { return tick(); } + close() { return tick(); } +} + +class Mixed { + static a = boot(); + run() { return tick(); } + static b = boot(); + stop() { return tick(); } +} + +class Host { + static cb = wrap(() => tick()); + go() { function helper() { return tick(); } return helper(); } +} + +module.exports = { Registry, Panel, Mixed, Host }; From 5d5eca2eadc83ee990d8944faeaf9e4cb64e5520 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:59:28 -0700 Subject: [PATCH 30/37] javascript: a one-line field initializer still defines the object and class methods written in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the initializer spanning its fields, `static cfg = make({ run() {…} });` gives the initializer and `run` the same one-line span, and equal spans at one depth were siblings: the defines hop from the initializer was lost, for object-literal methods and class-expression methods alike. On an equal span a class's initializer now holds a callable whose qualified name lies under its owner and which is not a member of the class (init_holds); a method of a one-line class and a function after it stay siblings. `file:line` on such a line names the inner callable. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 29 ++++++++++++++---- .../skills/axiomcode/scripts/axiomcode-path | 4 +++ .../field-initializer-is-a-sibling/case.json | 30 +++++++------------ .../field-initializer-is-a-sibling/src/app.js | 26 +++++++++++++++- 4 files changed, 63 insertions(+), 26 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index be2127937..a92016a18 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -286,6 +286,17 @@ def _sibling_of_init(display, init_display): return bool(owner) and display.rsplit('.', 1)[0] == owner and '.' in display +def init_holds(init_display, init_qname, display, qname): + """a named initializer holds a callable on its own line span: `static cfg = make({ run() {…} });` gives the + initializer and `run` the same one line, and line spans alone call them siblings. The qualified name says `run` + is written under the initializer's owner (`src/a.ObjLit.run`), and the display that it is not a member of it + (`run`, `ObjLit..m`), so the initializer defines it. A function written after a one-line class + has no such qualified name, and a method of the class is the initializer's sibling.""" + if not (init_display and _NAMED_INIT.search(init_display) and init_qname and qname and display): return False + if '.' not in init_qname or _sibling_of_init(display, init_display): return False + return qname.startswith(init_qname.rsplit('.', 1)[0] + '.') and qname != init_qname + + def _within(inner, outer): """a call site's (line, col, end_line, end_col) lies inside another's and is not the same one""" return inner != outer and outer[:2] <= inner[:2] and inner[2:] <= outer[2:] @@ -308,21 +319,27 @@ def defines_edges(callables, sites=None): """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id[, qualified_name]). `sites(method_ids)` returns (caller_id, line, col, end_line, end_col) for the call sites of those callables; it is asked only about lines where more than one callable could have defined an anonymous one.""" - byfile = {}; mid_of = {}; lex = {}; disp_of = {} + byfile = {}; mid_of = {}; lex = {}; disp_of = {}; qn_of = {} for f, ln, en, i, disp, mid, *qn in callables: if not (ln and en) or str(mid or i).startswith('generated:'): continue byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i; disp_of[i] = disp + qn_of[i] = qn[0] if qn else None if qn and qn[0] and str(mid or i).startswith(_LEXICAL_IDS): lex[i] = qn[0] + holds = lambda c, x: init_holds(disp_of[c], qn_of[c], disp_of[x], qn_of[x]) def may_hold(c, x): - """on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's""" - return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.') + """on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's, or + c is a class's initializer holding x (a member of an object or class written in it: `src/a.C.run`)""" + return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.') or holds(c, x) parent = {}; groups = [] for f, rows in byfile.items(): - rows.sort(); st = [] + # at an equal span a named initializer comes first, so what it holds on its line finds it on the stack + rows.sort(key=lambda r: (r[0], r[1], r[2], not _NAMED_INIT.search(disp_of[r[3]] or ''), r[3])); st = [] for ln, neg, d, i in rows: # pop what ends before this one ends (END against END, not against this one's start: the two agree on nested - # spans and not on overlapping ones), and a sibling: the same span at the same depth - while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d)): st.pop() + # spans and not on overlapping ones), and a sibling: the same span at the same depth, unless it is an + # initializer holding this one on its line (init_holds) + while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d + and not holds(st[-1][3], i))): st.pop() # past an equal span the name says it is not written in (kept on the stack: a later one may be), and past a # class's initializer when this is a member of that class beside it (#1663) j = len(st) - 1 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 1858846df..e0a773f0e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -270,6 +270,10 @@ class G: r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) if r: ids = [x['id'] for x in r if x['span'] == r[0]['span']] + # a field initializer and what it holds on its one line (`static cfg = make({ run() {…} });`) share the + # span; the line names the innermost, the callable written inside the initializer (#1663) + sy = lambda i: (self.sym.get(i, {}).get('display'), self.sym.get(i, {}).get('qualified_name')) + ids = [j for j in ids if not any(ax_edges.init_holds(*sy(j), *sy(i)) for i in ids if i != j)] or ids # AN ANNOTATION IS NOT THE FUNCTION. `export const f: (a: A) => B = (a) => …` puts a one-line call # signature and the arrow on the same line, and the signature, being shortest, won: `impact file:171` # answered for ``. Where every pick is a bodiless signature, the callable that starts diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/case.json b/tests/cases/javascript/field-initializer-is-a-sibling/case.json index 00fe2dab2..17ac8a0d2 100644 --- a/tests/cases/javascript/field-initializer-is-a-sibling/case.json +++ b/tests/cases/javascript/field-initializer-is-a-sibling/case.json @@ -1,21 +1,13 @@ {"lang": "javascript", "src": "src", "checks": [ - {"why": "a static field initializer defines only what is written in it, not the methods of its class", - "run": ["path", "Registry.", "tick"], - "want": ["no chain of resolved calls connects"], - "avoid": ["defines"], "expect_error": true}, - {"why": "nor does an instance field initializer", - "run": ["path", "Panel.", "tick"], - "want": ["no chain of resolved calls connects"], - "avoid": ["defines"], "expect_error": true}, - {"why": "a method written between two initialized fields is their sibling, not written inside them", - "run": ["path", "Mixed.", "tick"], - "want": ["no chain of resolved calls connects"], - "avoid": ["defines"], "expect_error": true}, - {"why": "control: an arrow handed to a call in the initializer is still reached from it, and a function inside a method is not", - "run": ["path", "Host.", "tick"], - "want": ["callback_registered", "", "tick"], - "avoid": ["defines", "helper"]}, - {"why": "control: a function declared inside a method is still defined by it", - "run": ["path", "Host.go", "tick"], - "want": ["Host.go", "helper", "tick"]}]} + {"why": "a static field initializer defines only what is written in it, not the methods of its class", "run": ["path", "Registry.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "nor does an instance field initializer", "run": ["path", "Panel.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "a method written between two initialized fields is their sibling, not written inside them", "run": ["path", "Mixed.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "control: an arrow handed to a call in the initializer is still reached from it, and a function inside a method is not", "run": ["path", "Host.", "tick"], "want": ["callback_registered", "", "tick"], "avoid": ["defines", "helper"]}, + {"why": "control: a function declared inside a method is still defined by it", "run": ["path", "Host.go", "tick"], "want": ["Host.go", "helper", "tick"]}, + {"why": "a one-line static initializer defines the object-literal method it hands to a call", "run": ["path", "ObjLit.", "tick"], "want": ["[defines", "run", "tick"]}, + {"why": "and the method of a class expression written in it", "run": ["path", "ClsExpr.", "tick"], "want": ["[defines", ".m", "tick"]}, + {"why": "a one-line instance initializer defines the method of the plain object it holds", "run": ["path", "PlainObj.", "tick"], "want": ["[defines", "onClick", "tick"]}, + {"why": "and of the object literal it hands to a call", "run": ["path", "InstObj.", "tick"], "want": ["[defines", "fetch", "tick"]}, + {"why": "the line of such an initializer names the callable written inside it, not both", "run": ["impact", "src/app.js:32"], "want": ["change: run (at src/app.js:32)"], "avoid": ["callables at src/app.js:32"]}, + {"why": "control: on a one-line class the method beside the initializer, and a function after the class, stay its siblings", "run": ["path", "OneLine.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}]} diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js index 563586d8c..97fab3bda 100644 --- a/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js +++ b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js @@ -26,4 +26,28 @@ class Host { go() { function helper() { return tick(); } return helper(); } } -module.exports = { Registry, Panel, Mixed, Host }; +function make(o) { return o; } + +class ObjLit { + static cfg = make({ run() { return tick(); } }); + other() { return 1; } +} + +class ClsExpr { + static Inner = wrap(class { m() { return tick(); } }); + other() { return 1; } +} + +class PlainObj { + handlers = { onClick() { return tick(); } }; + other() { return 1; } +} + +class InstObj { + api = make({ fetch() { return tick(); } }); + other() { return 1; } +} + +class OneLine { static n = boot(); m() { return tick(); } } function after() { return tick(); } + +module.exports = { Registry, Panel, Mixed, Host, ObjLit, ClsExpr, PlainObj, InstObj, OneLine, after }; From 88bed570c2d50a8ca928512e7797a68081c0890b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:25:10 -0700 Subject: [PATCH 31/37] context: a tie between entry points goes to code a reader can follow #1643 made ties in pick_seeds deterministic by falling to the display name. Name order is not neutral: a typing in a .d.ts ("node:sqlite"...), an anonymous and test data sort first, so equal-scored seeds went to declarations with no code to read and the flow shrank (one question on the engine repo: 230 -> 149 lines, TsLocalResolver.run replaced by StatementSync.run from a .d.ts). Before the name, a tie now goes to source over test code, then to a declaration with a body over a typing or an anonymous one. Case context-tie-goes-to-code-not-typings covers it, with a named-declaration control. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-context | 14 ++++++++++++-- .../context-tie-goes-to-code-not-typings/case.json | 11 +++++++++++ .../src/app/beta.ts | 6 ++++++ .../src/app/worker.ts | 6 ++++++ .../src/test/aaa.test.ts | 6 ++++++ .../src/typings/store.d.ts | 8 ++++++++ 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json create mode 100644 tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts create mode 100644 tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts create mode 100644 tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts create mode 100644 tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index e090f3d1d..33ed7d4c5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -227,7 +227,12 @@ def pick_seeds(g, scored, terms, want, named=(), prefer=frozenset()): directory a project is indexed in, so the same question on the same code picked a different four of nine from one copy to the next. A tie now goes first to `prefer` (a declaration bound by a text file of the kind the question asks about: the mapper method whose XML holds the SQL, when the question asks which SQL runs), then to the one - whose own name holds more of the matched words, then to its name and place, which do not move. + whose own name holds more of the matched words, then to one a reader can go to (below), then to its name and place, + which do not move. + + Name order alone is not neutral: `"node:sqlite".StatementSync.run` (a typing), `` and `AllMethodExamples` + (test data) sort before `TsLocalResolver.run`, so a tie went to what has no code to read. Before the name, a tie + goes to source over test code, then to a declaration with a body over a typing (`.d.ts`) or an anonymous one. """ seeds, seen, covered = [], set(), set() def take(sid, why, sc): @@ -238,7 +243,12 @@ def pick_seeds(g, scored, terms, want, named=(), prefer=frozenset()): # the task's words in the declaration's OWN name beat the same words reached through its owner or its path: # `OrderCreatedEvent` says more about "where is an order created" than `Order.getCreatedAt` does own = lambda sid: sum(t in ((g.sym.get(sid) or {}).get('name') or '').lower() for t in scored.get(sid, (0, []))[1]) - tie = lambda sid: (sid not in prefer, -own(sid), g.disp(sid) or '', g.loc(sid) or '', sid) + def readable(sid): + sy = g.sym.get(sid) or {} + f, nm = sy.get('file') or '', sy.get('name') or '' + anonymous = nm.startswith(('<', '"', "'")) and nm != '' # a constructor has a body to read + return (bool(sy.get('is_test') or TESTY.search(f)), f.endswith('.d.ts') or anonymous) + tie = lambda sid: (sid not in prefer, -own(sid)) + readable(sid) + (g.disp(sid) or '', g.loc(sid) or '', sid) ranked = sorted(scored.items(), key=lambda kv: (-kv[1][0],) + tie(kv[0])) floor = 0.05 * ranked[0][1][0] if ranked else 0 multi = [(sid, sc, m) for sid, (sc, m) in ranked if len(m) >= 2 and sc >= floor] diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json b/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json new file mode 100644 index 000000000..16d70fc7c --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json @@ -0,0 +1,11 @@ +{"lang": "typescript", + "src": "src", + "checks": [ + {"why": "entry points tied on score go to code a reader can follow before their names are compared: `\"store\".Statement.run` and `Alpha.run` are typings in a .d.ts and `AaaHarness.run` is test code, and all three sort before `Beta.run` by name", + "run": ["context", "how does run work"], + "want": [" 1 Beta.run ", " 3 Worker.run "], + "avoid": [" 1 \"store\"", " 1 Alpha.run ", " 1 AaaHarness.run "]}, + {"why": "control: a declaration the question names is the subject whatever file it is in; the tie-break does not move it", + "run": ["context", "how does Alpha.run work"], + "want": [" 1 Alpha.run "], + "avoid": []}]} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts new file mode 100644 index 000000000..c73d31d78 --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts @@ -0,0 +1,6 @@ +export class Beta { + run(): void { + this.prepare(); + } + prepare(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts new file mode 100644 index 000000000..185f6c06d --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts @@ -0,0 +1,6 @@ +export class Worker { + run(): void { + this.step(); + } + step(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts new file mode 100644 index 000000000..51c2bcb57 --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts @@ -0,0 +1,6 @@ +export class AaaHarness { + run(): void { + this.check(); + } + check(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts new file mode 100644 index 000000000..a90d2d6fd --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts @@ -0,0 +1,8 @@ +declare module "store" { + export class Statement { + run(): void; + } +} +export declare class Alpha { + run(): void; +} From c01f47d05aeca33c9e3e84af2dffe06e49abcd14 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:40:00 -0700 Subject: [PATCH 32/37] typescript: resolve calls through middleware parameters typed by an indexed access A middleware's `(set, get, api) => ...` is typed only by the function type it is returned as, and that type reaches the store's functions through an indexed access (`StoreApi['setState']`), a conditional alias (`Get`) or a type literal's method picked by key. None of those resolved, so every call through the parameters was ambiguous_unknown. - an inner arrow returned where a function type is expected takes its parameter types from that type's return (contextual-params.dl) - an indexed access with one string-literal key names that member, written in place or under an alias whose arguments supply the object or key; a method member makes the reference callable with its signatures - a conditional alias yields its branches, except an indexed-access branch A union or `keyof` key still names nothing (control in case 79). Case 79 added. Real state-management library src: ambiguous_unknown 144 -> 122, set/get/api.getState in the devtools middleware now dispatch to the store's own setState/getState. A real React app: 1659 edges, unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/logger.ts | 32 +++++++ .../src/store.ts | 44 +++++++++ .../src/tsconfig.json | 9 ++ ...79-function-typed-parameter-dispatch.edges | 17 ++++ ...-function-typed-parameter-dispatch.entries | 4 + ...function-typed-parameter-dispatch.envelope | 7 ++ ...9-function-typed-parameter-dispatch.fields | 0 ...ion-typed-parameter-dispatch.fields-oracle | 8 ++ ...ion-typed-parameter-dispatch.known-missing | 7 ++ ...9-function-typed-parameter-dispatch.oracle | 3 + ...function-typed-parameter-dispatch.type-use | 22 +++++ ...tion-typed-parameter-dispatch.types-oracle | 7 ++ .../engine/resolution/contextual-params.dl | 25 +++++ .../engine/resolution/member-lookup.dl | 3 + .../engine/resolution/type-resolution.dl | 95 +++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 12 +++ 16 files changed, 295 insertions(+) create mode 100644 graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts create mode 100644 graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts create mode 100644 graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use create mode 100644 graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts new file mode 100644 index 000000000..5a8f09d88 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts @@ -0,0 +1,32 @@ +import { StateCreator, StoreApi } from './store' + +// The middleware: its inner arrow's parameters carry no annotation and are typed by LoggerImpl's +// declared return type. +type LoggerImpl = (fn: StateCreator) => StateCreator + +const loggerImpl: LoggerImpl = (fn) => (set, get, api) => { + const logged: typeof set = (partial: any, replace?: any) => { + set(partial, replace) + console.log(get()) + } + console.log(api.getState()) + return fn(logged, get, api) +} +export const logger = loggerImpl + +// The same types written as annotations: nothing contextual. +export function explicitlyTyped(set: StoreApi['setState'], get: StoreApi['getState']): T { + set({}) + return get() +} + +// CONTROLS. An index that is not one literal names a union over several members, and a conditional +// alias bound to a non-literal key is the same question: both stay unresolved rather than picking one. +type Pick1 = K extends keyof T ? T[K] : never +export function controlUnionKey( + f: StoreApi['getState' | 'getInitialState'], + g: Pick1, 'getState' | 'getInitialState'>, +): void { + f() + g() +} diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts new file mode 100644 index 000000000..ccb767912 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts @@ -0,0 +1,44 @@ +// A MIDDLEWARE RECEIVES FUNCTIONS AS PARAMETERS (#838). `(set, get, api) => ...` is written with no +// annotation: each parameter is typed by the function type the middleware is declared to return, and +// that type reaches the store's functions only through an indexed access (`StoreApi['setState']`), +// a conditional alias (`Get`), and a member whose type is itself an indexed access over a +// type literal's method (`SetStateInternal`). Each call below resolves to the signature the compiler +// names, and the store's own `setState`/`getState` arrows are its dispatch candidates. +type SetStateInternal = { + _(partial: T | Partial, replace?: false): void + _(state: T, replace: true): void +}['_'] + +export interface StoreApi { + setState: SetStateInternal + getState: () => T + getInitialState: () => T + subscribe: (listener: (state: T, prev: T) => void) => () => void +} + +export interface StoreMutators {} +type Get = K extends keyof T ? T[K] : F +export type Mutate = Ms extends [] + ? S + : Ms extends [[infer Mi, infer Ma], ...infer Mrs] + ? Mutate[Mi & keyof StoreMutators], Mrs> + : never + +export type StateCreator = (( + set: Get, Mis>, 'setState', never>, + get: Get, Mis>, 'getState', never>, + api: Mutate, Mis>, +) => T) & { $$mutators?: Mis } + +export function createStore(createState: StateCreator): StoreApi { + let state: T + const setState: StoreApi['setState'] = (partial: T | Partial, replace?: boolean) => { + state = replace ? (partial as T) : Object.assign({}, state, partial) + } + const getState: StoreApi['getState'] = () => state + const subscribe: StoreApi['subscribe'] = (listener) => () => listener(state, state) + const getInitialState: StoreApi['getInitialState'] = () => state + const api: StoreApi = { setState, getState, getInitialState, subscribe } + state = createState(setState, getState, api) + return api +} diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges new file mode 100644 index 000000000..72ba632ed --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges @@ -0,0 +1,17 @@ +ambiguous_unknown FUNCTION_CALL logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) @L30 -> - +ambiguous_unknown FUNCTION_CALL logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) @L31 -> - +ambiguous_unknown METHOD_CALL logger#(?,?,?) @L12 -> - +ambiguous_unknown METHOD_CALL logger#logged(any,any) @L10 -> - +ambiguous_unknown METHOD_CALL store#setState(T | Partial,boolean) @L36 -> - +callback_registered FUNCTION_CALL logger#(?,?,?) @L13 -> logger#logged(any,any) +callback_registered FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#getState() +callback_registered FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#setState(T | Partial,boolean) +known_edge FUNCTION_CALL logger#(?,?,?) @L13 -> store#(Get,Get,Mutate) +known_edge FUNCTION_CALL logger#explicitlyTyped(StoreApi['setState'],StoreApi['getState']) @L19 -> store#_(T | Partial,false) +known_edge FUNCTION_CALL logger#explicitlyTyped(StoreApi['setState'],StoreApi['getState']) @L20 -> StoreApi#() +known_edge FUNCTION_CALL logger#logged(any,any) @L10 -> StoreApi#() +known_edge FUNCTION_CALL store#() @L39 -> StoreApi#(T,T) +known_edge FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#(Get,Get,Mutate) +known_edge METHOD_CALL logger#(?,?,?) @L12 -> StoreApi#() +multi_inferred FUNCTION_CALL logger#logged(any,any) @L9 -> store#_(T | Partial,false) +multi_inferred FUNCTION_CALL logger#logged(any,any) @L9 -> store#_(T,true) diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries new file mode 100644 index 000000000..2c5650045 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + exported_from_entry_module logger#controlUnionKey logger.ts:26 + exported_from_entry_module logger#explicitlyTyped logger.ts:18 + unimported_module logger# logger.ts:1 diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope new file mode 100644 index 000000000..871cfca1c --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope @@ -0,0 +1,7 @@ +value logger#@5:19 -> logger#@7 +value store#@14:13 -> store#@38 +value store#@15:20 -> store#@40 +value store#@16:14 -> store#@39 +value store#_@8:3 -> store#@35 +value store#_@9:3 -> store#@35 +value_call logger#@7 -> store#@38 diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle new file mode 100644 index 000000000..049975801 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle @@ -0,0 +1,8 @@ +79-function-typed-parameter-dispatch [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 1 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration + MISSING logger#(?,?,?) READ StoreApi#getState diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing new file mode 100644 index 000000000..391cbfd5a --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing @@ -0,0 +1,7 @@ +# Accepted gaps for 79-function-typed-parameter-dispatch — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# CONTROL: an indexed access whose key is a UNION names several members. The compiler merges the two +# identical signatures into one; the engine declines to pick a member for a non-literal key. +logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) -> StoreApi#() diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle new file mode 100644 index 000000000..1c5f6e451 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle @@ -0,0 +1,3 @@ +oracle=9 engine=9 agree=8 missing=1 (known 1, NEW 0) extra=1 + known logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) -> StoreApi#() + extra logger#logged(any,any) -> store#_(T,true) diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use new file mode 100644 index 000000000..342764e7b --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use @@ -0,0 +1,22 @@ +ambiguous_unknown AS_TARGET 0 store [EXPRESSION] -> - +ambiguous_unknown TYPE_ARGUMENT 2 LoggerImpl [TYPE] -> - +ambiguous_unknown TYPE_ELEMENT 1 store [METHOD_PARAM] -> - +known_edge CONDITIONAL_TRUE 2 Mutate [TYPE] -> Mutate +known_edge FIELD_TYPE 0 StoreApi [FIELD] -> SetStateInternal +known_edge METHOD_PARAM 0 logger [METHOD_PARAM] -> Pick1 +known_edge METHOD_PARAM 0 store [METHOD_PARAM] -> StateCreator +known_edge METHOD_PARAM 1 LoggerImpl [TYPE] -> StateCreator +known_edge METHOD_PARAM 3 StateCreator [TYPE] -> Get +known_edge METHOD_PARAM 3 StateCreator [TYPE] -> Mutate +known_edge METHOD_RETURN 0 store [METHOD] -> StoreApi +known_edge METHOD_RETURN 1 LoggerImpl [TYPE] -> StateCreator +known_edge TYPE_ARGUMENT 1 logger [METHOD_PARAM] -> StoreApi +known_edge TYPE_ARGUMENT 4 StateCreator [TYPE] -> Mutate +known_edge TYPE_ARGUMENT 4 StateCreator [TYPE] -> StoreApi +known_edge TYPE_ARGUMENT 5 StateCreator [TYPE] -> StoreApi +known_edge TYPE_ELEMENT 1 logger [METHOD_PARAM] -> StoreApi +known_edge TYPE_ELEMENT 1 store [VARIABLE] -> StoreApi +known_edge TYPE_ELEMENT 4 Mutate [TYPE] -> StoreMutators +known_edge TYPE_ELEMENT 6 Mutate [TYPE] -> StoreMutators +known_edge VARIABLE_TYPE 0 logger [VARIABLE] -> LoggerImpl +known_edge VARIABLE_TYPE 0 store [VARIABLE] -> StoreApi diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle new file mode 100644 index 000000000..2c07f6cbd --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle @@ -0,0 +1,7 @@ +79-function-typed-parameter-dispatch [types] + precision 1.0000 (12 correct, 0 wrong) + recall 1.0000 (12 of 12 the compiler resolved) + sites 32 resolved 27 (84.4%) + tiers ambiguous_unknown=5 known_edge=27 + contexts AS_TARGET=1 CONDITIONAL_TRUE=1 FIELD_TYPE=1 METHOD_PARAM=6 METHOD_RETURN=2 TYPE_ARGUMENT=8 TYPE_ELEMENT=11 VARIABLE_TYPE=2 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/contextual-params.dl b/graph/typescript/engine/resolution/contextual-params.dl index 4f3aa4a5f..b596d41f2 100644 --- a/graph/typescript/engine/resolution/contextual-params.dl +++ b/graph/typescript/engine/resolution/contextual-params.dl @@ -532,6 +532,31 @@ fn_written_as_fnref(lm, fref) :- var_initializer("client", _, e, v), var_type_ref(_, r, v), ref_shape_target(r, fref). +// …and an arrow RETURNED by a function whose return type is a function type: the middleware +// shape, `const logger: Impl = (fn) => (set, get, api) => { … }` or `function logger(fn): +// StateCreator { return (set, get, api) => … }` (#838). The inner arrow is written where +// the outer function's RETURN type is expected, so it takes its parameter types from there, +// as the compiler does. The return is the FUNCTION_TYPE's METHOD_RETURN child, or the +// function's own declared return type. +fn_returns_fn(outer, inner) :- method_returns_expr(outer, e), + expr_kind("client", _, "ROOT", e), + expr_anon_decl("client", inner, e). +fn_returns_fn(outer, inner) :- expr_root_context("client", "ARROW_BODY_EXPRESSION", e), + expr_kind("client", _, "ROOT", e), + expr_anon_decl("client", inner, e), + method_enclosing_member(inner, outer). +fn_type_return_ref(fref, rr) :- type_ref_parent(_, fref, _, rr), + type_ref(_, _, "METHOD_RETURN", _, _, _, _, rr). +fn_written_as_fnref(inner, fref2) :- fn_written_as_fnref(outer, fref1), + fn_returns_fn(outer, inner), + fn_type_return_ref(fref1, rr), + ref_shape_target(rr, fref2), + type_ref(_, "FUNCTION_TYPE", _, _, _, _, _, fref2). +fn_written_as_fnref(inner, fref) :- fn_returns_fn(outer, inner), + method_return_ref("client", r, outer), + ref_shape_target(r, fref), + type_ref(_, "FUNCTION_TYPE", _, _, _, _, _, fref). + // an unannotated parameter only; one the author annotated keeps its own type lambda_param_type(lp, prov, t) :- fn_written_as_fnref(lm, fref), fn_param_type(fref, k, prov, t), diff --git a/graph/typescript/engine/resolution/member-lookup.dl b/graph/typescript/engine/resolution/member-lookup.dl index bfd2d733f..f0e99b8e4 100644 --- a/graph/typescript/engine/resolution/member-lookup.dl +++ b/graph/typescript/engine/resolution/member-lookup.dl @@ -189,6 +189,9 @@ call_signature_in_scope(scope, m) :- lookup_scope(scope), method_owner(prov, asib, m), method_kind(prov, k, _, m), method_kind_is_call_signature(k). +// An indexed access that names a METHOD — `{ _(p: P): void }['_']`, `Api['run']` — is called +// with that method's signatures. The reference is its own shape (type-resolution.dl, #838). +call_signature_in_scope(ref, m) :- ref_member_method(ref, m). method_kind_is_call_signature("CALL_SIGNATURE"). method_kind_is_call_signature("TYPE_LITERAL_CALL_SIGNATURE"). method_kind_is_call_signature("FUNCTION_TYPE_SIGNATURE"). diff --git a/graph/typescript/engine/resolution/type-resolution.dl b/graph/typescript/engine/resolution/type-resolution.dl index b28091d3e..7f4b94038 100644 --- a/graph/typescript/engine/resolution/type-resolution.dl +++ b/graph/typescript/engine/resolution/type-resolution.dl @@ -323,6 +323,16 @@ tvar_under(p, n) :- type_ref(rp, "INDEXED_ACCESS", _, _, _, _, _, p), type_ref_parent(rp, p, "0", c), tvar_under(c, n). +// A CONDITIONAL alias yields its BRANCHES (#838), the same over-approximation +// ref_conditional_branch makes for a conditional written in place: `type Mutate = Ms +// extends [] ? S : …` read at `Mutate, Mis>` is StoreApi, so `api.getState()` on a +// middleware's `api` parameter has a receiver. A branch that is an INDEXED ACCESS is not +// descended: `K extends keyof T ? T[K] : F` denotes a MEMBER of T, never T itself, and +// ref_member answers it. +tvar_under(p, n) :- ref_conditional_branch(p, c), + !type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, c), + tvar_under(c, n). + ref_type_target(ref, prov, t) :- alias_rhs(ref, _, rhs), tvar_under(rhs, pname), type_arg_binding(ref, pname, argRef), @@ -378,6 +388,91 @@ ref_type_target(ia, prov, t) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia) field_type_ref(_, fr, f), ref_type_target(fr, prov, t). +// ── …AND WHAT THE MEMBER IS WHEN IT IS A FUNCTION (#838) ─────────────────── +// The rule above names a member's type only when that type is a DECLARATION. The commonest +// member an indexed access picks out is a FUNCTION: `const setState: StoreApi['setState'] +// = …`, a middleware parameter declared `StoreApi['getState']`. Its type is a FUNCTION_TYPE +// (a shape), or a METHOD of the object, and neither is anything ref_type_target can name, so +// every call through such a value resolved to nothing. On a state-management library 43 of 115 +// executed production call sites were this shape, each reaching exactly one function at run +// time. +// +// ref_member(Ref, Obj, Name): the reference denotes member Name of Obj. Written in place +// (`Obj["name"]`), or under an ALIAS whose arguments supply the object, the key, or both: +// `type Get = K extends keyof T ? T[K] : F` read at `Get, 'setState', +// never>`. The key is always ONE string literal — written, or bound to a type argument that is +// one. A union key or a `keyof` names several members and stays unresolved, as the rule above +// already decides for its own case. +ia_literal_key(ia, n) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "1", iref), + ref_string_literal(iref, n). +ref_string_literal(r, n) :- type_ref(_, "LITERAL", _, _, lit, _, _, r), + strlen(lit) > 2, + q = substr(lit, 0, 1), + string_quote(q), + substr(lit, strlen(lit) - 1, 1) = q, + n = substr(lit, 1, strlen(lit) - 2). +string_quote("'"). +string_quote("\""). + +ia_object(ia, obj) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "0", oref), + ref_type_target(oref, _, obj). +ia_object(ia, obj) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "0", oref), + ref_shape_target(oref, obj). + +ref_member(ia, obj, n) :- ia_literal_key(ia, n), ia_object(ia, obj). + +// Under an alias: the indexed access sits in the alias's right-hand side, directly, inside a +// parenthesis, or as a branch of a conditional (the branches only, as ref_conditional_branch +// decides). A child that is one of the ALIAS's type variables is read at the reference. +ia_under(rhs, rhs) :- type_alias_target(_, rhs, _). +ia_under(rhs, c) :- ia_under(rhs, p), ref_conditional_branch(p, c). +ia_under(rhs, c) :- ia_under(rhs, p), + type_ref(rp, k, _, _, _, _, _, p), + ref_kind_is_transparent(k), + type_ref_parent(rp, p, _, c). +alias_ia(ref, ia) :- alias_rhs(ref, _, rhs), + ia_under(rhs, ia), + type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia). +alias_ia_key(ref, ia, n) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "1", iref), + type_ref(_, "TYPE_VARIABLE", _, kn, _, _, _, iref), + type_arg_binding(ref, kn, arg), + ref_string_literal(arg, n). +alias_ia_object(ref, ia, obj) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "0", oref), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, oref), + type_arg_binding(ref, tn, arg), + ref_type_target(arg, _, obj). +alias_ia_object(ref, ia, obj) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "0", oref), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, oref), + type_arg_binding(ref, tn, arg), + ref_shape_target(arg, obj). +// the key bound at the reference, the object written or bound +ref_member(ref, obj, n) :- alias_ia_key(ref, ia, n), ia_object(ia, obj). +ref_member(ref, obj, n) :- alias_ia_key(ref, ia, n), alias_ia_object(ref, ia, obj). +// the key written, the object bound at the reference +ref_member(ref, obj, n) :- alias_ia(ref, ia), ia_literal_key(ia, n), alias_ia_object(ref, ia, obj). + +// What the member is. A FIELD contributes its declared type (named or a shape); a METHOD makes +// the reference callable with that method's signatures, so the reference is its own shape and +// call_signature_in_scope (member-lookup.dl) reads ref_member_method for it. Same positive-only +// route as the rule above: declared_field / declared_method, never the lookup relations. +ref_type_target(ref, prov, t) :- ref_member(ref, obj, n), + declared_field(obj, n, _, f), + field_type_ref(_, fr, f), + ref_type_target(fr, prov, t). +ref_shape_target(ref, s) :- ref_member(ref, obj, n), + declared_field(obj, n, _, f), + field_type_ref(_, fr, f), + ref_shape_target(fr, s). +ref_member_method(ref, m) :- ref_member(ref, obj, n), + declared_method(obj, n, _, m). +ref_shape_target(ref, ref) :- ref_member_method(ref, _). + // BY NAME, and only where the exact link is absent. Keeping both unguarded would put // the shadowed declaration's constraint alongside the right one at every such site, // which is a superset the exact link exists to remove. diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index e5ffef4be..60091ddc7 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -612,3 +612,15 @@ .decl ts_di_token_param(c0:symbol) .decl ts_di_fits(c0:symbol,c1:symbol,c2:symbol) .decl ts_di_satisfied(c0:symbol) +.decl ia_literal_key(c0:symbol,c1:symbol) +.decl ref_string_literal(c0:symbol,c1:symbol) +.decl string_quote(c0:symbol) +.decl ia_object(c0:symbol,c1:symbol) +.decl ref_member(c0:symbol,c1:symbol,c2:symbol) +.decl ia_under(c0:symbol,c1:symbol) +.decl alias_ia(c0:symbol,c1:symbol) +.decl alias_ia_key(c0:symbol,c1:symbol,c2:symbol) +.decl alias_ia_object(c0:symbol,c1:symbol,c2:symbol) +.decl ref_member_method(c0:symbol,c1:symbol) +.decl fn_returns_fn(c0:symbol,c1:symbol) +.decl fn_type_return_ref(c0:symbol,c1:symbol) From 4f676ea01757de410a6b594f75fb4290a33ecaf7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:48:18 -0700 Subject: [PATCH 33/37] Read hook: a call in the text read is not an edge the text does not show A caller was judged visible by its own declaration line, and the range by the span of the declarations in it. A script's top level (``, declared at L1, calls after the last function) was therefore never visible, so reading a script's functions got `main L56 <- trend. L1` for each of them: the calls at the bottom of the text just read. That shape was most of the Read blocks agents never acted on. - a caller is visible when one of its call sites lies in the range read, and the range is what was read, not the declarations' span; a top-level caller outside the range is printed at the line of its call - a block whose only content is bare unresolved counts is not emitted - anonymous functions (``, ``, `Owner.`) are counted in `+N more` but not named: there is no name to grep or ask about Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- plugins/axiomcode/hooks/enrich.py | 28 +++++++++++++++++---- tests/enrich_lines.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 6d1dbc198..7a37fae89 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -260,11 +260,23 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" # ranked ★ (connected to earlier Reads) first, then few-caller methods, then the rest as one line; 6 lines at most lo, hi = rows[0]['line'], rows[-1]['end_line'] or b # the lines the text actually covers mids = [r['method_id'] for r in rows]; ids = [r['id'] for r in rows]; ph = ','.join('?' * len(rows)) - def visible(f, ln): return f == rel and lo <= ln <= hi # the other end is in the text the model just read + # the other end is in the text the model just read: the range READ, which runs past the last declaration (a script's + # top-level code after its last function is text the model has), not the span of the declarations in it + def visible(f, ln): return f == rel and min(a, lo) <= ln <= max(b - 1, hi) + # A CALLER IS SHOWN BY THE CALL, NOT BY ITS DECLARATION. Judged by the caller's own line, a file's top level (its + # ``, declared at L1) was never in a range that did not start at L1, so reading a script's functions got + # `main L56 ← trend. L1` for every one of them — the calls at the bottom of the very text just read. In the + # loop's runs that shape was most of the Read blocks nobody acted on. A caller whose call site lies in the range is + # visible; the caller's line is the fallback where a graph records no site line. up = collections.defaultdict(list); anyup = set() # anyup: has a caller at all, shown or not - for e in q(f"SELECT DISTINCT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): - anyup.add(e['m']) - if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in up[e['m']]}: up[e['m']].append(e) # overloads of one caller are one name + sites = collections.defaultdict(list) + for e in q(f"SELECT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln, cr.kind k, cs.start_line sl FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id LEFT JOIN call_sites cs ON cs.id = e.call_site_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): + sites[(e['m'], e['id'])].append(e) + for (m, _), es in sites.items(): + anyup.add(m); e = dict(es[0]) + if any(visible(x['f'], x['sl'] or x['ln']) for x in es): continue + if e['k'] == 'module': e['ln'] = min((x['sl'] for x in es if x['sl']), default=e['ln']) # a top level is where its call is + if e['d'] not in {x['d'] for x in up[m]}: up[m].append(e) # overloads of one caller are one name dn = collections.defaultdict(list) for e in q(f"SELECT DISTINCT e.caller_id c, ce.id, ce.display d, ce.file f, ce.line ln FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id IN ({ph}) AND e.callee_provenance = 'client'", *ids): if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in dn[e['c']]}: dn[e['c']].append(e) @@ -290,6 +302,9 @@ def visible(f, ln): return f == rel and lo <= ln <= hi # the # one, the whole file after a range of it, or the same file through another spelling of its path. A key on the # read's own arguments caught only the first of those. info = [x for x in info if x['r']['id'] not in done] + # A BLOCK OF BARE UNRESOLVED COUNTS NAMES NOTHING TO GO TO (`main L44 ?7 unresolved call(s)` and no edge): no caller, + # callee or override to open, so it is context spent on a number. Kept when one declaration carries anything else. + if not any(x['up'] or x['dn'] or x['ovi'] or x['ovo'] or task_hits(x['r']['display']) for x in info): info = [] block_ids = [x['r']['id'] for x in info] # an edge into a file the agent has not opened is what a read cannot show it; one into a file it has read is # something it may already have seen from the other end @@ -329,7 +344,10 @@ def line(x): shown = (picked + few)[:5] if rows else [] for x in shown: lines.append(line(x)) left = [x for x in info if x not in shown] if rows else [] - if left: lines.append(" " + ('+%d more: ' % len(left)) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") + # an anonymous function (``, ``) has no name to grep or ask about: counted in `+N more`, not listed + anon = lambda x: re.fullmatch(r'<[\w-]+>', x['r']['display'].rsplit('.', 1)[-1]) is not None # `Walker.` too + n_left = len(left); left = [x for x in left if not anon(x)] + if left: lines.append(" " + ('+%d more: ' % n_left) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(_where._abs(inp['path'], scwd)), os.path.realpath(cwd)).split(os.sep)[0] == '..'): # (a Grep of a path outside this tree is about another codebase — nothing here to add) # a real search is rarely one identifier: `hasNext\(\)|\.next\(\)|close\(\)`, `getScanner|RTBoundValidator|withSSTablesIterated`. diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 324e205fc..d723b263f 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -171,6 +171,48 @@ def edit(session, text, hook='enrich.py', event='PostToolUse'): check('the prompt-time report of a body edit is the same one line', 'body edit of OrderStore.findById' in out and 'reads / uses it' not in out, out) open(f, 'w').write(orig) +# ── what a Read of a script says: a call in the text read is not an edge the text does not show ───────────── +PY = { + 'tool.py': ('from lib import fetch\n\n\n' + 'def helper(x):\n return fetch(x)\n\n\n' + 'def main():\n return helper(1)\n\n\n' + 'def untyped(o):\n return o.go()\n\n\n' + 'if __name__ == "__main__":\n main()\n'), + 'lib.py': ('def fetch(x):\n return x\n\n\n' + 'def untyped2(o):\n return o.go()\n'), + 'app.py': ('from lib import untyped2\n\n\ndef run(o):\n return untyped2(o)\n'), + 'many.py': ''.join(f'def f{i}(o):\n return o.go()\n\n\n' for i in range(7)) + + 'g1 = lambda o: o.a()\ng2 = lambda o: o.b()\n\n\n' + # an owner-qualified one (`Walker.`) is as anonymous as a bare one + + 'class Walker:\n h = lambda self, o: o.c()\n', + 'use_many.py': 'import many\n\n\ndef use(o):\n' + ''.join(f' many.f{i}(o)\n' for i in range(7)) + + ' many.g1(o)\n many.g2(o)\n', +} +with tempfile.TemporaryDirectory() as repo: + for n, t in PY.items(): open(os.path.join(repo, n), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + subprocess.run(['bash', AX, 'index', repo, '--lang', 'python'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + check('the python project indexes', False) + else: + tool = os.path.join(repo, 'tool.py') + r = fire(repo, 'p1', 'Read', {'file_path': tool}) + check('a whole-script read does not list its own top level as a caller', '' not in r, r) + check('control: the cross-file callee it cannot show is still there', 'helper ←0 →1' in r, r) + r = fire(repo, 'p2', 'Read', {'file_path': tool, 'offset': 8, 'limit': 3}) + check('control: a range that leaves out the top-level call names it, at the line of the call', + 'main L8' in r and 'tool. L17' in r, r) + r = fire(repo, 'p3', 'Read', {'file_path': tool, 'offset': 12, 'limit': 3}) + check('a block of nothing but unresolved counts is not emitted', r == '', r) + r = fire(repo, 'p4', 'Read', {'file_path': os.path.join(repo, 'lib.py'), 'offset': 5, 'limit': 3}) + check('control: the same unresolved call on a declaration with a caller is kept', + 'untyped2 L5 ← run' in r and 'unresolved' in r, r) + r = fire(repo, 'p5', 'Read', {'file_path': os.path.join(repo, 'many.py')}) + more = line_of(r, '+') + check('anonymous functions, bare or owner-qualified, are counted in +N more but not named', + ('+5 more' in more or '+4 more' in more) and '' not in more, r) + check('control: the named ones left over are still named there', 'f' in more.split(':', 1)[-1], r) + print() print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) sys.exit(1 if fails else 0) From d85e5a83ac0354b27631fd01c6090c31fb529fd2 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:31 -0700 Subject: [PATCH 34/37] impact: follow a TypeScript structural type to the sites that never name it Adding a required property to an interface breaks code that is checked against it without writing its name. Three such shapes are now answered: - a union or object type alias over the type: its users break, and they name only the alias (followed through aliases of aliases); a type alias is not listed as an entry point; - an object literal carrying the tag of a discriminated interface, `{ kind: 'LiteralNode', ... }`, reached for the interface and its bases; - an intrinsic JSX element whose attributes the project's IntrinsicElements table checks against the props type. Rows are [by name]. Every TypeScript interface answer also says that other structurally checked sites are not in it and that `tsc --noEmit` lists them. Closes #784 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 34 +++- .../skills/axiomcode/scripts/dl/impact.dl | 23 +++ .../skills/axiomcode/scripts/graph_sql.py | 171 +++++++++++++++++- .../typescript/discriminant-literal/case.json | 5 + .../discriminant-literal/src/ast.ts | 13 ++ .../discriminant-literal/src/transformer.ts | 18 ++ .../discriminant-literal/tsconfig.json | 1 + .../typescript/jsx-intrinsic-props/case.json | 6 + .../jsx-intrinsic-props/src/jsx.d.ts | 14 ++ .../jsx-intrinsic-props/src/page.tsx | 18 ++ .../jsx-intrinsic-props/tsconfig.json | 1 + .../typescript/union-alias-member/case.json | 5 + .../union-alias-member/src/consumer.ts | 9 + .../union-alias-member/src/state.ts | 12 ++ .../union-alias-member/src/unrelated.ts | 5 + 15 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 tests/cases/typescript/discriminant-literal/case.json create mode 100644 tests/cases/typescript/discriminant-literal/src/ast.ts create mode 100644 tests/cases/typescript/discriminant-literal/src/transformer.ts create mode 100644 tests/cases/typescript/discriminant-literal/tsconfig.json create mode 100644 tests/cases/typescript/jsx-intrinsic-props/case.json create mode 100644 tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts create mode 100644 tests/cases/typescript/jsx-intrinsic-props/src/page.tsx create mode 100644 tests/cases/typescript/jsx-intrinsic-props/tsconfig.json create mode 100644 tests/cases/typescript/union-alias-member/case.json create mode 100644 tests/cases/typescript/union-alias-member/src/consumer.ts create mode 100644 tests/cases/typescript/union-alias-member/src/state.ts create mode 100644 tests/cases/typescript/union-alias-member/src/unrelated.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e74bbfa1f..a72639277 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -78,7 +78,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # it obtained itself, so an added proxied annotation does not apply") — the differential test caught that immediately. # `ref` is not here either: the alongside layer reads it to tell a sibling that touches the same field from one that does # not, for EVERY target kind. What is left is the layer only a field or type target can reach. -_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', 'base_name', 'field_type', 'reexport', 'reexport_from', 'switch_over'} _CONFIG = {'config', 'config_key_known', 'config_site'} # `reexport` is NOT out of a method's reach: rules 396 and 398 both start at target(q,"method",m,_) — the barrel @@ -903,10 +903,24 @@ class Impact: W('ref', refs); W('qualifier', sorted(set(quals))) W('registration', ax_registration.registrations(g.q, g.site_file)) trs = []; trf = set() + # a reference on a module-level type alias's own lines belongs to the alias, not the module (#784) + import graph_sql + aspans, anames = graph_sql.type_aliases(g.q) + W('type_alias', sorted((a,) for a in anames)) + # an intrinsic element and the props type its tag maps to: `

` is checked against FormElementProps (#784) + props = graph_sql.jsx_props(g.q) + W('jsx_props', sorted(props)) + W('jsx_tag', graph_sql.jsx_tags({s['file'] for s in g.sym.values() if s.get('file')}, self.code, self.at, + {tag for tag, _n in props})) + # a discriminated type's tag, and the object literals that write it: `{ kind: 'LiteralNode', … }` (#784) + disc = graph_sql.discriminants(g.q) + W('discriminant', sorted(disc)) + W('keyed_literal', graph_sql.keyed_literals(g.q, self.code, self.at, {v for _t, _k, v in disc})) + holder = graph_sql.typeref_holder(self.at, aspans, mods) if g.has('type_refs'): for r in g.q("SELECT name, file, line, context FROM type_refs"): if r['line'] and r['line'] > 0: - c = self.at(r['file'], r['line']) + c = holder(r['file'], r['line']) if c: trs.append((c, r['name'], r['context'], r['file'], r['line'])) else: trf.add((r['file'], r['name'], r['context'])) W('typeref', trs); W('typeref_file', sorted(trf)) @@ -1805,7 +1819,11 @@ def main(argv): direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside' and cert != 'stubs it' and not why.startswith('stubs a method of this name')} | {c for c, _ in contract} direct_ids = {c for c in direct_ids if c in g.sym} - ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if not callers_of.get(m) or g.sym[m]['is_test']] + # a type alias holds the hop to its users (#784) but is nothing a framework or a runner invokes + import graph_sql + aliases = graph_sql.type_aliases(g.q)[1] + ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() + if (not callers_of.get(m) or g.sym[m]['is_test']) and m not in aliases] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite @@ -2607,6 +2625,16 @@ def main(argv): print("bound: the closure could not start — no dependent above is an edge it can walk back from, so what " "reaches THEM is not in this answer") if u: print(f"bound: {u} unresolved call(s) inside the impacted set — what those reach is unknown, so the set is a lower bound") + # A STRUCTURAL TYPE IS SATISFIED WITHOUT BEING NAMED (#784). `return { kind: 'LiteralNode', fragments }` checks + # against the interface and never writes it, so an added required property or a narrowed one breaks sites no row + # above can name. Assignability is the compiler's job; the answer says so rather than looking complete. + shapes = sorted({g.sym[t]['name'] for k, _, pay in targets if k == 'type' for t in (pay if isinstance(pay, list) else []) + if isinstance(t, str) and t in g.sym and g.sym[t].get('kind') in ('interface', 'type') + and (g.sym[t].get('file') or '').endswith(('.ts', '.tsx', '.mts', '.cts'))}) + if shapes: + print(f"bound: an object literal, a spread or a JSX element that builds a {' / '.join(shapes[:3])} without " + "writing the name is checked against it structurally and is not in this answer — after adding a required " + "property or narrowing one, `tsc --noEmit` lists those sites") if dunder and not reached: print(f"outside the graph: {', '.join(dunder)} is a protocol method — the language runtime calls it (an operator, a conversion, a lifecycle hook), which is no call site, so nothing reaching it here does NOT mean nothing does") if outside and lone: print("outside the graph: " + ', '.join(f"{o} extends / implements {a}, which the graph does not contain" for o, a in outside[:3]) + " — a library or framework can call this method, so " + ("what reaches it above is not all that does" if reached else "nothing reaching it here does NOT mean nothing does")) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 970c8da10..5eab025ed 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -18,6 +18,11 @@ // qualifier(f, l, n, Q) on line f:l the name n is written qualified as Q.n // typeref(c, n, ctx, f, l) a type reference to n in context ctx (field type, method param …) inside c, at f:l // typeref_file(f, n, ctx) the same when the parser gives no line (Java): the file only +// type_alias(t) t is a TypeScript type alias: a reference on its own lines is held by it, not the module +// jsx_props(tag, n) an IntrinsicElements member: the attributes of are checked against type n +// jsx_tag(c, tag, f, l) an intrinsic element rendered inside c, at f:l +// discriminant(t, k, v) type t declares property k typed as the one string literal v +// keyed_literal(c, k, v, f, l) an object literal inside c writes the property k: 'v', at f:l // type_in_file(T, f) type T is declared in file f // literal(c, v, f, l) a string literal v inside c // field(F, T, n, f, l) field F named n declared in type T at f:l @@ -52,6 +57,11 @@ .decl qualifier(f:symbol, l:number, n:symbol, q:symbol) .input qualifier .decl typeref(c:symbol, n:symbol, ctx:symbol, f:symbol, l:number) .input typeref .decl typeref_file(f:symbol, n:symbol, ctx:symbol) .input typeref_file +.decl type_alias(t:symbol) .input type_alias +.decl jsx_props(tag:symbol, n:symbol) .input jsx_props +.decl jsx_tag(c:symbol, tag:symbol, f:symbol, l:number) .input jsx_tag +.decl discriminant(t:symbol, k:symbol, v:symbol) .input discriminant +.decl keyed_literal(c:symbol, k:symbol, v:symbol, f:symbol, l:number) .input keyed_literal .decl type_in_file(t:symbol, f:symbol) .input type_in_file .decl literal(c:symbol, v:symbol, f:symbol, l:number) .input literal .decl dec_literal(c:symbol, d:symbol, v:symbol, f:symbol, l:number) .input dec_literal @@ -507,6 +517,19 @@ direct(q, c, "produces", "instantiates it (unresolved site)", "by name", f, l) : direct(q, c, "produces", "reflects on its .class — deserialization or a framework produces it here", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), ref(c, n, _, "CLASS_LITERAL", f, l), !inside_target(q, c). direct(q, c, "uses", "references it", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), ref(c, n, _, ek, f, l), ek != "CLASS_LITERAL", !local_kind(ek), !member_kind(ek), !inside_target(q, c). direct(q, c, "uses", cat("names it (", ctx, ")"), "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), typeref(c, n, ctx, f, l), !inside_target(q, c). +// the ALIAS HOP (#784): a type alias whose right-hand side names the target, directly or through another such alias, +// and everything that names the alias. `function finalize(s: DraftState)` breaks when MapState changes and never writes MapState. +.decl alias_over(q:symbol, a:symbol) +alias_over(q, a) :- target(q, "type", t, _), typ(t, n, _), typeref(a, n, _, _, _), type_alias(a), !inside_target(q, a). +alias_over(q, a) :- alias_over(q, b), typ(b, n, _), typeref(a, n, _, _, _), type_alias(a), !inside_target(q, a). +direct(q, c, "uses", cat("names ", an, ", a type alias over it (", ctx, ")"), "by name", f, l) :- alias_over(q, a), typ(a, an, _), typeref(c, an, ctx, f, l), c != a, !inside_target(q, c). +// an INTRINSIC ELEMENT whose attributes are checked against it (#784): `

` never writes FormElementProps +direct(q, c, "uses", cat("renders <", tag, ">, whose attributes are checked against it"), "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), jsx_props(tag, n), jsx_tag(c, tag, f, l), !inside_target(q, c). +direct(q, c, "uses", cat("renders <", tag, ">, whose attributes are checked against ", sn, ", which extends it"), "by name", f, l) :- target(q, "type", t, _), extends(s, t), typ(s, sn, _), jsx_props(tag, sn), jsx_tag(c, tag, f, l), !inside_target(q, c). +// an OBJECT LITERAL that carries its discriminant (#784): `freeze({ kind: 'LiteralNode', fragments })` is checked against +// LiteralNode and never writes the name, so an added required property breaks it +direct(q, c, "produces", cat("builds an object literal with the tag ", k, ": '", v, "'"), "by name", f, l) :- target(q, "type", t, _), discriminant(t, k, v), keyed_literal(c, k, v, f, l), !inside_target(q, c). +direct(q, c, "produces", cat("builds an object literal with the tag ", k, ": '", v, "' of ", sn, ", which extends it"), "by name", f, l) :- target(q, "type", t, _), extends(s, t), typ(s, sn, _), discriminant(s, k, v), keyed_literal(c, k, v, f, l), !inside_target(q, c). direct(q, c, "uses", "names it (a signature or a declaration)", "text", f, l) :- target(q, "type", t, _), typ(t, n, _), textuse(c, n, f, l), !inside_target(q, c). direct(q, c, "uses", cat("uses ", n, ", imported from it"), "by name", f, l) :- target(q, "type", _, _), importuse(c, n, f, l), !inside_target(q, c). direct(q, c, "reads", cat("calls the generated getter ", an, "()"), "by name", f, l) :- target(q, "type", t, _), gen(t, "get"), field(fl, t, _, _, _), accessor(fl, an, "read"), unresolved(c, an, k, f, l), !ctor_kind(k), !inside_target(q, c). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 9c6cd0dec..6347339ee 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -20,7 +20,7 @@ answering from call_edges alone under-reported (4 callers as 2). impact() returns None there and the caller falls back, which is right for that kind. """ -import os, re, sqlite3, json, collections, sys +import bisect, os, re, sqlite3, json, collections, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_edges import ax_registration @@ -1663,6 +1663,105 @@ def direct_for_newconst(q, tids, code, inside): return sorted(set(rows)), switchers +def type_aliases(q): + """`type_alias(a)` with each alias's span and name: file -> [(line, end, id)], and id -> name (#784). + + A TypeScript `type DraftState = ObjectState | MapState` is a declaration of its own, but no callable spans it, so + the innermost-callable walk charged every name on its right-hand side to the module initializer. That lost the + one hop that matters: the code that breaks when a union member changes names only the alias.""" + if not (_has(q, 'types') and _has(q, 'symbols')): return {}, {} + spans, names = {}, {} + for i, n, f, a, b in q("""SELECT s.id, s.name, s.file, s.line, s.end_line FROM symbols s JOIN types t ON t.id = s.id + WHERE t.category = 'TYPE_ALIAS_TYPE' AND s.file IS NOT NULL AND s.line > 0"""): + spans.setdefault(f, []).append((a, b or a, i)); names[i] = n + return spans, names + + +def typeref_holder(at, spans, modules): + """`typeref(c, …)`'s c for a type reference at (f, l): the innermost callable, except that a reference on a type + alias's own lines, where the only callable spanning it is the module, belongs to the alias. An alias declared + inside a function body keeps the function: that is where its users are.""" + def holder(f, l): + c = at(f, l) + if c is None or c in modules: + best = None + for a, b, i in spans.get(f, ()): + if a <= l <= b and (best is None or b - a < best[0]): best = (b - a, i) + if best: return best[1] + return c + return holder + + +def jsx_props(q): + """`jsx_props(tag, n)`: the types an intrinsic element's attributes are checked against, read off the members of every + `IntrinsicElements` interface the project declares — `h1: FormElementProps` — each name in the member's type, so + `React.DetailedHTMLProps, …>` gives FormHTMLAttributes too (#784).""" + if not (_has(q, 'fields') and _has(q, 'types')): return set() + out = set() + for tag, tn in q("""SELECT f.name, f.type_name FROM fields f JOIN types t ON t.id = f.owner_type_id + WHERE t.name = 'IntrinsicElements' AND f.type_name IS NOT NULL AND f.type_name <> ''"""): + out |= {(tag, n) for n in re.findall(r'(?:[\w$]+\.)*([A-Za-z_$][\w$]*)', tn)} + return out + + +_JSX_OPEN = re.compile(r'<([a-z][\w-]*)(?=[\s/>])') +_JSX_AFTER_WORD = {'return', 'yield', 'await', 'throw', 'case', 'default', 'else'} # `return ` is an element + + +def jsx_tags(files, code, at, tags): + """`jsx_tag(c, tag, f, l)`: an intrinsic element `

` opened in a .tsx / .jsx file, charged to the callable it is + rendered in. Only the tags an IntrinsicElements table names, and only where `<` cannot close a type argument or a + comparison: the character before it is not an identifier, a `)`, a `]` or a `.` (`useState`, `a= 0 and text[j] in ' \t\r\n': j -= 1 + if j >= 0 and (text[j].isalnum() or text[j] in '_$)].'): + w = re.search(r'[\w$]+$', text[max(0, j - 15):j + 1]) + if not (w and w.group(0) in _JSX_AFTER_WORD): continue + l = bisect.bisect_left(nl, m.start()) + 1 + c = at(f, l) + if c: rows.append((c, m.group(1), f, l)) + return rows + + +def discriminants(q): + """`discriminant(t, k, v)`: t declares a property k whose type is the one string literal v — the tag of a + discriminated union, `readonly kind: 'LiteralNode'` (#784).""" + if not (_has(q, 'fields') and _has(q, 'types')): return set() + out = set() + for t, k, tn in q("""SELECT owner_type_id, name, type_name FROM fields + WHERE owner_type_id IS NOT NULL AND (type_name LIKE '''%''' OR type_name LIKE '"%"')"""): + m = re.fullmatch(r"""\s*(['"])([^'"\\]+)\1\s*""", tn or '') + if m: out.add((t, k, m.group(2))) + return out + + +def keyed_literals(q, code, at, values): + """`keyed_literal(c, k, v, f, l)`: an object literal inside c writes the property `k: 'v'` — the discriminant of a + type it builds without naming it. Read from the literals table, which holds string EXPRESSIONS only (a literal + type `kind: 'X'` in an interface is not there), then confirmed on the line: `node.kind === 'X'` compares and + builds nothing, so it is not a row.""" + rows = [] + if not values or not _has(q, 'literals'): return rows + for v, f, l in q(f"SELECT DISTINCT value, file, line FROM literals WHERE value IN ({','.join('?' * len(values))})", + *sorted(values)): + L = code(f); text = L[l - 1] if 0 < l <= len(L) else '' + c = at(f, l) + if not c: continue + # the reader blanks a string's body, quotes included, so `kind: 'X'` reads as `kind:` and a run of blanks as + # wide as the string; a reader that does not blank it leaves the quoted form + w = len(v) + 2 + keys = re.findall(rf"""([A-Za-z_$][\w$]*)\s*:\s*(?:(['"]){re.escape(v)}\2|\s{{{w}}}(?=\s|[,}}]|$))""", text) + rows.extend((c, k, v, f, l) for k, _q in keys) + return sorted(set(rows)) + + def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): """`direct(q,c,role,why,cert,f,l)` for a TYPE target — who instantiates it, calls into it, names it. @@ -1679,6 +1778,11 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): for t in tids: r = q("SELECT name, kind FROM symbols WHERE id=?", t) if r: names[t] = (r[0][0], r[0][1]) + aspans, anames = type_aliases(q) + holder = typeref_holder(at, aspans, {i for (i,) in q("SELECT id FROM symbols WHERE kind = 'module'")} + if aspans else set()) + def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE line > 0 AND name = ?", n) + over, todo = set(), [] # alias_over(q, a), and the aliases still to expand # ── a type the container INJECTS (rule 186) ──────────────────────────────────────────────────────────── # direct(q,c,"uses",cat("receives it by dependency injection (",kind,") — …"),"resolved","",0) @@ -1801,14 +1905,73 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): rows.append((c, 'uses', 'references it', 'by name', f, l)) # 273 — the name written in a type position: the context says which (a field type, a parameter, a cast) if _has(q, 'type_refs'): - for nm, f, l, ctx in q("""SELECT name, file, line, context FROM type_refs - WHERE line > 0 AND name = ?""", n): - c = at(f, l) + for nm, f, l, ctx in trefs(n): + c = holder(f, l) if c and c not in inside: rows.append((c, 'uses', f'names it ({ctx})', 'by name', f, l)) + if c in anames and c not in inside and c not in over: over.add(c); todo.append(c) # 274 — the name in the text of a file the parser gave no line for for c, nm, f, l in textuse: if nm == n and c not in inside: rows.append((c, 'uses', 'names it (a signature or a declaration)', 'text', f, l)) + # the ALIAS HOP (#784): a type alias whose right-hand side names the target, directly or through another such + # alias, and everything that names the alias. `function finalize(s: DraftState)` breaks when MapState changes + # and never writes MapState. + # alias_over(q,a) :- target(q,"type",t,_), typ(t,n,_), typeref(a,n,_,_,_), type_alias(a), !inside_target(q,a) + # alias_over(q,a) :- alias_over(q,b), typ(b,n,_), typeref(a,n,_,_,_), type_alias(a), !inside_target(q,a) + # direct(q,c,"uses",cat("names ",an,", a type alias over it (",ctx,")"),"by name",f,l) + # :- alias_over(q,a), typ(a,an,_), typeref(c,an,ctx,f,l), c != a, !inside_target(q,c) + while todo: + b = todo.pop() + for _nm, f, l, _ctx in trefs(anames[b]): + c = holder(f, l) + if c in anames and c not in inside and c not in over: over.add(c); todo.append(c) + for a in sorted(over): + an = anames[a] + for _nm, f, l, ctx in trefs(an): + c = holder(f, l) + if c and c != a and c not in inside: + rows.append((c, 'uses', f'names {an}, a type alias over it ({ctx})', 'by name', f, l)) + # an INTRINSIC ELEMENT whose attributes are checked against it (#784): `

` never writes FormElementProps + # direct(q,c,"uses",cat("renders <",tag,">, whose attributes are checked against it"),"by name",f,l) + # :- target(q,"type",t,_), typ(t,n,_), jsx_props(tag,n), jsx_tag(c,tag,f,l), !inside_target(q,c) + # … and the same through a props type that extends it: cat("…checked against ",sn,", which extends it") + # :- target(q,"type",t,_), extends(s,t), typ(s,sn,_), jsx_props(tag,sn), jsx_tag(c,tag,f,l), !inside_target(q,c) + props = jsx_props(q) if code is not None else set() + if props: + own = {names[t][0] for t in tids if t in names} + subs = {} if not _has(q, 'type_ancestors') else { + n: s for s, n in q(f"""SELECT a.type_id, s.name FROM type_ancestors a JOIN symbols s ON s.id = a.type_id + WHERE a.ancestor_type_id IN ({','.join('?' * len(tids))})""", *tids)} + why = {} + for tag, n in props: + if n in own: why.setdefault(tag, set()).add('it') + if n in subs: why.setdefault(tag, set()).add(f'{n}, which extends it') + files = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE file LIKE '%.tsx' OR file LIKE '%.jsx'")} + for c, tag, f, l in jsx_tags(files, code, at, set(why)): + if c in inside: continue + for w in sorted(why[tag]): + rows.append((c, 'uses', f'renders <{tag}>, whose attributes are checked against {w}', 'by name', f, l)) + # an OBJECT LITERAL that carries its discriminant (#784): `freeze({ kind: 'LiteralNode', fragments })` builds a + # LiteralNode and is checked against it, so an added required property breaks it; it never writes the name. + # direct(q,c,"produces",cat("builds an object literal with its tag ",k,": '",v,"'"),"by name",f,l) + # :- target(q,"type",t,_), discriminant(t,k,v), keyed_literal(c,k,v,f,l), !inside_target(q,c) + # … and through a subtype: cat("… with the tag ",k,": '",v,"' of ",sn,", which extends it") + # :- target(q,"type",t,_), extends(s,t), typ(s,sn,_), discriminant(s,k,v), keyed_literal(c,k,v,f,l), !inside_target(q,c) + if code is not None and tids: + disc = discriminants(q) + tset = set(tids) + sub = {} if not _has(q, 'type_ancestors') else { + s: n for s, n in q(f"""SELECT a.type_id, s.name FROM type_ancestors a JOIN symbols s ON s.id = a.type_id + WHERE a.ancestor_type_id IN ({','.join('?' * len(tids))})""", *tids)} + want = {} + for t, k, v in disc: + if t in tset: want.setdefault((k, v), set()).add('') + elif t in sub: want.setdefault((k, v), set()).add(f' of {sub[t]}, which extends it') + if want: + for c, k, v, f, l in keyed_literals(q, code, at, {v for _k, v in want}): + if c in inside or (k, v) not in want: continue + for w in sorted(want[(k, v)]): + rows.append((c, 'produces', f"builds an object literal with the tag {k}: '{v}'{w}", 'by name', f, l)) # 275 — a name imported FROM the type (a static import), used here. Keyed on the imported member, not on the # type's own name, so it is not inside the per-type loop. for c, nm, f, l in importuse: diff --git a/tests/cases/typescript/discriminant-literal/case.json b/tests/cases/typescript/discriminant-literal/case.json new file mode 100644 index 000000000..e5f91cb44 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/case.json @@ -0,0 +1,5 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an object literal that carries a discriminated interface's tag is checked against it and never writes its name, so an added required property breaks it (#784)", "run": ["impact", "LiteralNode"], "want": ["transformLiteral src/transformer.ts:5 — builds an object literal with the tag kind: 'LiteralNode'"], "avoid": ["isLiteral", "makeRaw"]}, + {"why": "the base of the family reaches the literal through the member that extends it", "run": ["impact", "AstNode"], "want": ["builds an object literal with the tag kind: 'LiteralNode' of LiteralNode, which extends it"], "avoid": ["isLiteral"]}, + {"why": "control: the other member's literal is its own, and a comparison with the tag builds nothing", "run": ["impact", "RawNode"], "want": ["makeRaw"], "avoid": ["transformLiteral", "isLiteral"]}]} diff --git a/tests/cases/typescript/discriminant-literal/src/ast.ts b/tests/cases/typescript/discriminant-literal/src/ast.ts new file mode 100644 index 000000000..b671a46f5 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/src/ast.ts @@ -0,0 +1,13 @@ +export interface AstNode { readonly kind: string } + +export interface LiteralNode extends AstNode { + readonly kind: 'LiteralNode' + readonly fragments: ReadonlyArray +} + +export interface RawNode extends AstNode { + readonly kind: 'RawNode' + readonly sql: string +} + +export function freeze(o: T): Readonly { return Object.freeze(o) } diff --git a/tests/cases/typescript/discriminant-literal/src/transformer.ts b/tests/cases/typescript/discriminant-literal/src/transformer.ts new file mode 100644 index 000000000..f25068bf4 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/src/transformer.ts @@ -0,0 +1,18 @@ +import { freeze, RawNode } from './ast' + +// rebuilds a LiteralNode and never writes the interface's name +export function transformLiteral(node: { fragments: ReadonlyArray }) { + return accept(freeze({ kind: 'LiteralNode' as const, fragments: node.fragments })) +} + +// CONTROL: only compares the discriminant, builds nothing +export function isLiteral(node: { kind: string }): boolean { + return node.kind === 'LiteralNode' +} + +// CONTROL: builds the other member of the family +export function makeRaw(sql: string): RawNode { + return { kind: 'RawNode', sql } +} + +function accept(n: unknown): unknown { return n } diff --git a/tests/cases/typescript/discriminant-literal/tsconfig.json b/tests/cases/typescript/discriminant-literal/tsconfig.json new file mode 100644 index 000000000..bfebedc51 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/cases/typescript/jsx-intrinsic-props/case.json b/tests/cases/typescript/jsx-intrinsic-props/case.json new file mode 100644 index 000000000..e7b6da1bc --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/case.json @@ -0,0 +1,6 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a props type reached through a tag name:
and

map to FormElementProps in the project's IntrinsicElements, and the file that renders them never writes the name", "run": ["impact", "FormElementProps"], "want": ["Page src/page.tsx:3 — renders , whose attributes are checked against it", "also: renders

, whose attributes are checked against it"], "avoid": ["Picture", "Compare"]}, + {"why": "a base of the props types reaches every tag whose props type extends it", "run": ["impact", "HtmlGlobalProps"], "want": ["renders , whose attributes are checked against FormElementProps, which extends it", "Picture src/page.tsx:11 — renders , whose attributes are checked against ImageProps, which extends it"], "avoid": ["Compare"]}, + {"why": "control: a tag after `return` is an element; a comparison, a string and a comment are not, and a tag mapped to another props type is not listed", "run": ["impact", "ImageProps"], "want": ["Picture src/page.tsx:11 — renders "], "avoid": ["Page ", "Compare", "renders ", "renders

"]}, + {"why": "the answer for an interface says the structurally checked sites are not in it", "run": ["impact", "ImageProps"], "want": ["is checked against it structurally and is not in this answer", "tsc --noEmit"]}]} diff --git a/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts b/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts new file mode 100644 index 000000000..c7e2e90ef --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts @@ -0,0 +1,14 @@ +// the project supplies its own JSX namespace: each tag is checked against the props type it maps to +export interface HtmlGlobalProps { class?: string } +export interface FormElementProps extends HtmlGlobalProps { action?: string } +export interface ImageProps extends HtmlGlobalProps { src: string } + +declare global { + namespace JSX { + interface IntrinsicElements { + form: FormElementProps + h1: FormElementProps + img: ImageProps + } + } +} diff --git a/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx b/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx new file mode 100644 index 000000000..aec412539 --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx @@ -0,0 +1,18 @@ +export function Page(): unknown { + return ( + +

title

+
+ ) +} + +// CONTROL: renders only an img, whose props are ImageProps +export function Picture(): unknown { + return +} + +// CONTROL: a comparison, a string and a comment that spell a tag render nothing +export function Compare(a: number, img: number): string { + //
in a comment + return a' : '' +} diff --git a/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json b/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json new file mode 100644 index 000000000..f75ffe5b1 --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "strict": true } } diff --git a/tests/cases/typescript/union-alias-member/case.json b/tests/cases/typescript/union-alias-member/case.json new file mode 100644 index 000000000..09b0ade4c --- /dev/null +++ b/tests/cases/typescript/union-alias-member/case.json @@ -0,0 +1,5 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a union member reaches the code that names only the union alias, and an alias over that alias, through the alias declaration", "run": ["impact", "MapState"], "want": ["DraftState src/state.ts:6 — names it (TYPE_ELEMENT)", "finalize src/consumer.ts:3 — names DraftState, a type alias over it (METHOD_PARAM)", "AnyState src/state.ts:9 — names DraftState, a type alias over it (TYPE_ELEMENT)", "maybe src/consumer.ts:7 — names AnyState, a type alias over it (METHOD_PARAM)"], "avoid": ["state.", "unrelated", "show "]}, + {"why": "the alias itself reaches an alias over it and that alias's users; a type alias is not listed as an entry point", "run": ["impact", "DraftState"], "want": ["finalize src/consumer.ts:3 — names it (METHOD_PARAM)", "maybe src/consumer.ts:7 — names AnyState, a type alias over it (METHOD_PARAM)"], "avoid": ["hop(s) AnyState", "unrelated"]}, + {"why": "control: an alias with no member in common is answered as before, by the one function that names it", "run": ["impact", "Plain"], "want": ["show src/unrelated.ts:3 — names it (METHOD_PARAM)"], "avoid": ["finalize", "maybe", "a type alias over it"]}]} diff --git a/tests/cases/typescript/union-alias-member/src/consumer.ts b/tests/cases/typescript/union-alias-member/src/consumer.ts new file mode 100644 index 000000000..7df49cc79 --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/consumer.ts @@ -0,0 +1,9 @@ +import { AnyState, DraftState } from './state' + +export function finalize(state: DraftState): string { + return state.kind +} + +export function maybe(state: AnyState): string { + return state ? finalize(state) : '' +} diff --git a/tests/cases/typescript/union-alias-member/src/state.ts b/tests/cases/typescript/union-alias-member/src/state.ts new file mode 100644 index 000000000..1a66bc758 --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/state.ts @@ -0,0 +1,12 @@ +export interface ObjectState { kind: 'object'; value: object } +export interface MapState { kind: 'map'; value: Map } +export interface SetState { kind: 'set'; value: Set } + +// the union the consumers write; none of them names a member +export type DraftState = ObjectState | MapState | SetState + +// a second hop: an alias over the alias +export type AnyState = DraftState | null + +// CONTROL: an alias whose members are unrelated to MapState +export type Plain = { n: number } | string diff --git a/tests/cases/typescript/union-alias-member/src/unrelated.ts b/tests/cases/typescript/union-alias-member/src/unrelated.ts new file mode 100644 index 000000000..b2d83626e --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/unrelated.ts @@ -0,0 +1,5 @@ +import { Plain } from './state' + +export function show(p: Plain): string { + return typeof p === 'string' ? p : String(p.n) +} From 053ac99005124d950ae4634b7d7625420a045a60 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:59:29 -0700 Subject: [PATCH 35/37] typescript: try a merged interface's later declaration block first The compiler lists a merged interface's overloads block by block with each later block ahead of the earlier ones, so a module augmentation's overload, or lib.es2015.symbol.wellknown's String.match/replace/split/search, wins over the original when the argument fits both. The engine committed to the earlier block because the exact-type specificity rule preferred it. - order blocks only where the IR states it: augmentation after its target, same-file blocks in source order, lib.es5 before the other lib.es* files; overload indexes compare only within one block - the later block wins only where every argument is shown to fit it (concrete match, callback, or an argument type declaring every required member), so s.match("x") stays on lib.es5 - an augmentation block sees its file's imports and top-level names, so `find(m: Matcher)` inside `declare module "./text"` has a parameter type - a library argument type that declares every member a client interface requires is no longer rejected against it (prune-only) Case 78-merged-interface-later-block-first pins both orders with controls (Bare against Matcher keeps the first block). Closes #422 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/augment.ts | 7 + .../src/repro.ts | 15 ++ .../src/text.ts | 8 + .../src/tsconfig.json | 1 + .../src/twice.ts | 12 ++ ...8-merged-interface-later-block-first.edges | 8 + ...merged-interface-later-block-first.entries | 7 + ...erged-interface-later-block-first.envelope | 1 + ...-merged-interface-later-block-first.fields | 0 ...-interface-later-block-first.fields-oracle | 7 + ...-merged-interface-later-block-first.oracle | 2 + ...erged-interface-later-block-first.type-use | 21 +++ ...d-interface-later-block-first.types-oracle | 7 + .../engine/expression-resolution/overload.dl | 150 +++++++++++++++++- .../engine/resolution/name-resolution.dl | 28 ++++ graph/typescript/souffle/decls_all.dl | 23 +++ 16 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts create mode 100644 graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts create mode 100644 graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts create mode 100644 graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json create mode 100644 graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.edges create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.entries create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.envelope create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.fields create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.oracle create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.type-use create mode 100644 graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts new file mode 100644 index 000000000..b82c2b039 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts @@ -0,0 +1,7 @@ +import type { Matcher } from "./text"; +declare module "./text" { + interface Text { find(m: Matcher): 2; } + interface Typed { take(m: Matcher): 2; } + interface Strict { hold(m: Matcher): 2; } +} +export {}; diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts new file mode 100644 index 000000000..ae6a6301f --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts @@ -0,0 +1,15 @@ +import "./augment"; +import type { Text, Plain, Typed, Strict, Rx, Bare } from "./text"; +export function use(t: Text, s: string, p: Plain): void { + t.find(/x/); + s.match(/x/); + p.pick(/x/); +} +// The augmentation's block is tried first, and a Rx fits its Matcher. +export function typed(t: Typed, r: Rx): void { + t.take(r); +} +// Control: a Bare does not fit the augmentation's Matcher, so the original stays. +export function strict(t: Strict, b: Bare): void { + t.hold(b); +} diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts new file mode 100644 index 000000000..3f5d77bfb --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts @@ -0,0 +1,8 @@ +export interface Matcher { exec(s: string): unknown } +export interface Text { find(m: RegExp): 1; } +export interface Plain { pick(m: RegExp): 1; } + +export class Rx { exec(s: string): unknown { return s; } } +export class Bare { size(): number { return 0; } } +export interface Typed { take(m: Rx): 1; } +export interface Strict { hold(m: Bare): 1; } diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json new file mode 100644 index 000000000..59e429ea5 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "target": "es2022", "lib": ["es2022"], "module": "commonjs", "moduleResolution": "node10", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true }, "include": ["."] } diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts new file mode 100644 index 000000000..49c9c18f2 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts @@ -0,0 +1,12 @@ +import type { Matcher, Rx, Bare } from "./text"; +// Two blocks of one interface in one file: the compiler tries the second block first. +export interface Twice { go(m: Rx): 1; } +export interface Twice { go(m: Matcher): 2; } +// Control: the second block's parameter does not fit a Bare, so the first block stays. +export interface Once { keep(m: Bare): 1; } +export interface Once { keep(m: Matcher): 2; } + +export function run(t: Twice, o: Once, r: Rx, b: Bare): void { + t.go(r); + o.keep(b); +} diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.edges b/graph/test/typescript/expected/78-merged-interface-later-block-first.edges new file mode 100644 index 000000000..0090fca3b --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.edges @@ -0,0 +1,8 @@ +ambiguous_unknown METHOD_CALL repro#use(Text,string,Plain) @L5 -> - +known_edge METHOD_CALL repro#strict(Strict,Bare) @L14 -> Strict#hold(Bare) +known_edge METHOD_CALL repro#typed(Typed,Rx) @L10 -> Typed#take(Matcher) +known_edge METHOD_CALL repro#use(Text,string,Plain) @L6 -> Plain#pick(RegExp) +known_edge METHOD_CALL twice#run(Twice,Once,Rx,Bare) @L10 -> Twice#go(Matcher) +known_edge METHOD_CALL twice#run(Twice,Once,Rx,Bare) @L11 -> Once#keep(Bare) +multi_inferred METHOD_CALL repro#use(Text,string,Plain) @L4 -> Text#find(Matcher) +multi_inferred METHOD_CALL repro#use(Text,string,Plain) @L4 -> Text#find(RegExp) diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.entries b/graph/test/typescript/expected/78-merged-interface-later-block-first.entries new file mode 100644 index 000000000..5b6b2fc87 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.entries @@ -0,0 +1,7 @@ +── entry_point (6) ── + exported_from_entry_module repro#strict repro.ts:13 + exported_from_entry_module repro#typed repro.ts:9 + exported_from_entry_module repro#use repro.ts:3 + exported_from_entry_module twice#run twice.ts:9 + unimported_module repro# repro.ts:1 + unimported_module twice# twice.ts:1 diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope b/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope new file mode 100644 index 000000000..dcced6240 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope @@ -0,0 +1 @@ +structural text#Matcher.exec -> text#Rx.exec diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.fields b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle new file mode 100644 index 000000000..c938f6100 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle @@ -0,0 +1,7 @@ +78-merged-interface-later-block-first [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle new file mode 100644 index 000000000..911a5160e --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle @@ -0,0 +1,2 @@ +oracle=6 engine=7 agree=6 missing=0 (known 0, NEW 0) extra=1 + extra repro#use(Text,string,Plain) -> Text#find(RegExp) diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use b/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use new file mode 100644 index 000000000..e900db3ef --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use @@ -0,0 +1,21 @@ +ambiguous_unknown METHOD_PARAM 0 Plain [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 Text [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 Once [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 Once [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Strict [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 Strict [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Text [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Twice [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Twice [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 Typed [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Typed [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Plain +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Strict +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Text +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Typed +known_edge METHOD_PARAM 0 twice [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 twice [METHOD_PARAM] -> Rx +multi_inferred METHOD_PARAM 0 twice [METHOD_PARAM] -> Once +multi_inferred METHOD_PARAM 0 twice [METHOD_PARAM] -> Twice diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle new file mode 100644 index 000000000..3001c866d --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle @@ -0,0 +1,7 @@ +78-merged-interface-later-block-first [types] + precision 1.0000 (19 correct, 0 wrong) + recall 1.0000 (19 of 19 the compiler resolved) + sites 21 resolved 19 (90.5%) + tiers ambiguous_unknown=2 known_edge=17 multi_inferred=4 + contexts METHOD_PARAM=23 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index ee1170ed1..486fd5f08 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -273,6 +273,39 @@ arg_compatible_at(ce, m, pos) :- expr_call_candidate(ce, m), arg_type(ce, pos, at), param_type_at(m, pos, pt), type_satisfies(at, pt). +// AN ARGUMENT THAT HAS EVERY MEMBER THE INTERFACE REQUIRES. type_satisfies only tests +// client classes, so a LIBRARY type passed where a client interface is expected — a +// RegExp for `interface Matcher { exec(s: string): unknown }` — had no route here and +// was rejected, although the compiler accepts it structurally (#422). The same name-based +// cover test, asked only of the (argument, parameter) pairs that nothing above accepted. +// Prune-only: it can only keep a candidate, never remove one. +arg_param_uncovered(ce, pos, at, pt) :- expr_call_candidate(ce, m), + arg_type(ce, pos, at), + param_type_at(m, pos, pt), + interface_type(pt), + at != pt, + !same_type_entity(at, pt), + !type_subtype_star(at, pt). +compat_required(pt, name) :- arg_param_uncovered(_, _, _, pt), + scope_sibling(pt, sib), + declared_method(sib, name, "false", _). +compat_required(pt, name) :- arg_param_uncovered(_, _, _, pt), + scope_sibling(pt, sib), + declared_field(sib, name, "false", f), + field_kind(_, _, _, "false", f). +compat_required_count(pt, n) :- arg_param_uncovered(_, _, _, pt), + n = count : { compat_required(pt, _) }. +compat_covers_member(at, pt, name) :- arg_param_uncovered(_, _, at, pt), + compat_required(pt, name), + member_name_declared(at, name). +arg_type_covers(at, pt) :- arg_param_uncovered(_, _, at, pt), + compat_required_count(pt, k), + k > 0, + k = count : { compat_covers_member(at, pt, _) }. +arg_compatible_at(ce, m, pos) :- expr_call_candidate(ce, m), + arg_type(ce, pos, at), + param_type_at(m, pos, pt), + arg_type_covers(at, pt). arg_compatible_at(ce, m, pos) :- arg_is_target_typed(ce, pos), expr_call_candidate(ce, m), param_type_at(m, pos, _). @@ -760,7 +793,11 @@ specificity_beaten(ce, loser) :- overload_selectable(ce, winner), method_group_of(loser, g), arg_matches_concretely(ce, winner, pos), !arg_matches_concretely(ce, loser, pos), - !specificity_worse_somewhere(ce, winner, loser). + !specificity_worse_somewhere(ce, winner, loser), + // Not against a later declaration block the argument is shown to fit: the compiler + // tries that block FIRST, so a concrete match in an earlier block is never reached + // (#422). See LATER DECLARATION BLOCKS FIRST below. + !block_order_wins(ce, loser, winner). // A SPECIALIZED SIGNATURE BEATS A GENERAL ONE AT THE SAME POSITION, which is a // specificity fact rather than an ordering one. Both `on(event: "close")` and @@ -805,7 +842,116 @@ overload_beaten(ce, m) :- overload_selectable(ce, m), method_group_of(m2, g), overload_index_of(m2, j), j < i, - !specificity_beaten(ce, m2). + !specificity_beaten(ce, m2), + // An index counts within its own declaration block only: it restarts at 0 in every + // block, so across two blocks whose order is known it compares nothing. + !block_tried_before(m, m2). + +// ── LATER DECLARATION BLOCKS FIRST (#422) ─────────────────────────────────── +// A merged interface lists its members block by block, and the compiler's candidate +// order (`reorderCandidates`) puts each LATER block ahead of the earlier ones while +// keeping source order inside a block. So a module augmentation's `find(m: Matcher)` +// is tried before the original `find(m: RegExp)`, and `lib.es2015.symbol.wellknown`'s +// `match(matcher: { [Symbol.match](…) })` before `lib.es5`'s `match(regexp: string | +// RegExp)`: with a RegExp argument both apply and the later one is what tsc selects. +// The engine committed to the earlier one, because the concrete-match specificity rule +// above preferred the exact `RegExp` parameter. +// +// Block order is only claimed where the IR states it: +// - an augmentation (`declare module "./x"`, `declare global`) is merged after the +// declarations it augments; +// - two blocks in one file merge in source order; +// - `lib.es5.d.ts` is the root every other `lib.es*.d.ts` references, so it comes first. +// Two ordinary files reopening one interface are in program order, which the IR does not +// carry; nothing is claimed there and the answer stays the set it was. +method_block(m, b) :- method_owner(_, b, m), b != "". +method_in_augmentation(m) :- method_module(_, mh, m), + module_decl(_, _, _, _, k, mh), + module_kind_is_augmentation(k). +module_kind_is_augmentation("MODULE_AUGMENTATION"). +module_kind_is_augmentation("GLOBAL_AUGMENTATION"). + +// Only pairs that are overloads of one another in different blocks are ever compared. +cross_block_pair(late, early) :- method_group_of(late, g), + method_group_of(early, g), + late != early, + method_block(late, bl), + method_block(early, be), + bl != be. + +// block_tried_before(Late, Early): the compiler tries Late's block before Early's. +block_tried_before(late, early) :- cross_block_pair(late, early), + method_in_augmentation(late), + !method_in_augmentation(early). +block_tried_before(late, early) :- cross_block_pair(late, early), + method_lines(_, fp, ll, _, late), + method_lines(_, fp, le, _, early), + ll > le, + !augmentation_differs(late, early). +block_tried_before(late, early) :- cross_block_pair(late, early), + method_lines(_, fl, _, _, late), + method_lines(_, fe, _, _, early), + match(".*lib\\.es5\\.d\\.ts", fe), + match(".*lib\\.es[0-9]+\\.[a-z.]*d\\.ts", fl). +augmentation_differs(a, b) :- cross_block_pair(a, b), method_in_augmentation(a), !method_in_augmentation(b). +augmentation_differs(a, b) :- cross_block_pair(a, b), method_in_augmentation(b), !method_in_augmentation(a). + +// ── The later candidate wins only where the argument is SHOWN to fit it ────── +// Order on top of a weak applicability test is the trade the note below refuses. So the +// later block's candidate wins only where every argument has positive evidence: a +// concrete match, a callback against a callable parameter, or an argument type that +// declares every required member of the parameter's type. `s.match("x")` keeps +// `lib.es5`: `String` declares no `[Symbol.match]`, so the wellknown signature has no +// evidence and the earlier block is still the answer. +block_order_candidate(ce, late, early) :- overload_selectable(ce, late), + overload_selectable(ce, early), + block_tried_before(late, early). + +param_target_at(m, pos, t) :- block_order_candidate(_, m, _), + param_decl(_, _, pos, _, m, p), + param_type(p, _, t). +param_target_at(m, pos, s) :- block_order_candidate(_, m, _), + param_decl(_, _, pos, _, m, p), + param_shape_target(p, s). +param_target_required(t, name) :- param_target_at(_, _, t), + scope_sibling(t, sib), + declared_method(sib, name, "false", _). +param_target_required(t, name) :- param_target_at(_, _, t), + scope_sibling(t, sib), + declared_field(sib, name, "false", f), + field_kind(_, _, _, "false", f). +param_target_required_count(t, n) :- param_target_at(_, _, t), + n = count : { param_target_required(t, _) }. + +arg_param_pair(ce, pos, at, pt) :- block_order_candidate(ce, m, _), + arg_type(ce, pos, at), + param_target_at(m, pos, pt). +arg_covers_member(at, pt, name) :- arg_param_pair(_, _, at, pt), + param_target_required(pt, name), + member_name_declared(at, name). +arg_covers_param(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_type(ce, pos, at), + param_target_at(m, pos, pt), + param_target_required_count(pt, k), + k > 0, + k = count : { arg_covers_member(at, pt, _) }. + +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_matches_concretely(ce, m, pos). +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_covers_param(ce, m, pos). +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_is_function_value(ce, pos), + param_call_signature(m, pos). +arg_evidence_gap(ce, m) :- block_order_candidate(ce, m, _), + expr_child("client", ce, "ARGUMENT", pos, _), + !arg_evidenced(ce, m, pos). + +block_order_wins(ce, late, early) :- block_order_candidate(ce, late, early), + !arg_evidence_gap(ce, late). + +overload_beaten(ce, early) :- block_order_wins(ce, late, early), + !specificity_beaten(ce, late). // ── WHY THERE IS NO "FIRST APPLICABLE" TIE-BREAK ─────────────────────────── // TypeScript's own rule is that the first APPLICABLE overload wins, and implementing diff --git a/graph/typescript/engine/resolution/name-resolution.dl b/graph/typescript/engine/resolution/name-resolution.dl index 8366246bb..535226291 100644 --- a/graph/typescript/engine/resolution/name-resolution.dl +++ b/graph/typescript/engine/resolution/name-resolution.dl @@ -49,12 +49,40 @@ name_binds_any(mod, name, prov, k, ent) :- type_module("client", mod, ns), namespace_member("client", ns, name, k, ent), prov = "client". +// (2c) inside an AUGMENTATION block — `declare module "./text" { … }` or `declare +// global { … }` — the names of the FILE around it. The block is its own module row, so +// a reference written in it is keyed to that row, while the file's imports and +// top-level declarations belong to the file's row. The compiler nests the block's scope +// inside the file's: `import type { Matcher } from "./text"` above the block is what +// `find(m: Matcher)` inside it means. Without this every such annotation resolved to +// nothing, and the augmented member lost its parameter types (#422). +augmentation_host(aug, host) :- module_decl("client", _, _, fp, k, aug), + module_kind_is_augmentation(k), + module_decl("client", _, _, fp, hk, host), + host != aug, + module_kind_is_file(hk). +module_kind_is_file("SOURCE_MODULE"). +module_kind_is_file("DECLARATION_FILE"). +module_kind_is_file("SCRIPT_GLOBAL"). +name_binds_any(aug, name, prov, k, ent) :- augmentation_host(aug, host), + import_binding("client", _, name, _, host, ih), + name != "", + !module_local_decl(_, aug, name, _, _), + import_binds_any(ih, prov, k, ent). +name_binds_any(aug, name, prov, k, ent) :- augmentation_host(aug, host), + module_local_decl(prov, host, name, k, ent), + !module_local_decl(_, aug, name, _, _). + // (3) the global scope, ONLY when the name is not bound in the module itself. This // is the one precedence rule the language needs: a module that declares its own // `Buffer` means its own, and admitting the global as a second answer would leave // every call on it multi_inferred across two unrelated declarations. name_bound_locally(mod, name) :- import_binding("client", _, name, _, mod, _), name != "". name_bound_locally(mod, name) :- module_local_decl(_, mod, name, _, _). +name_bound_locally(aug, name) :- augmentation_host(aug, host), + import_binding("client", _, name, _, host, _), name != "". +name_bound_locally(aug, name) :- augmentation_host(aug, host), + module_local_decl(_, host, name, _, _). // DEMAND-GATED. Ungated, this rule is |client modules| x |global declarations| — // measured on this corpus, 418 modules against 5,268 globals produced 2.1 MILLION // name_binds_any rows, almost none of which any rule reads. Gating on the names the diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index e5ffef4be..2f6177472 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -377,6 +377,29 @@ .decl new_expression_type(c0:symbol,c1:symbol,c2:symbol) .decl object_prototype_member(c0:symbol,c1:symbol) .decl overload_beaten(c0:symbol,c1:symbol) +.decl method_block(c0:symbol,c1:symbol) +.decl method_in_augmentation(c0:symbol) +.decl module_kind_is_augmentation(c0:symbol) +.decl cross_block_pair(c0:symbol,c1:symbol) +.decl block_tried_before(c0:symbol,c1:symbol) +.decl augmentation_differs(c0:symbol,c1:symbol) +.decl block_order_candidate(c0:symbol,c1:symbol,c2:symbol) +.decl param_target_at(c0:symbol,c1:symbol,c2:symbol) +.decl param_target_required(c0:symbol,c1:symbol) +.decl param_target_required_count(c0:symbol,c1:number) +.decl arg_param_pair(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl arg_covers_member(c0:symbol,c1:symbol,c2:symbol) +.decl arg_covers_param(c0:symbol,c1:symbol,c2:symbol) +.decl arg_evidenced(c0:symbol,c1:symbol,c2:symbol) +.decl arg_evidence_gap(c0:symbol,c1:symbol) +.decl block_order_wins(c0:symbol,c1:symbol,c2:symbol) +.decl augmentation_host(c0:symbol,c1:symbol) +.decl module_kind_is_file(c0:symbol) +.decl arg_param_uncovered(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl compat_required(c0:symbol,c1:symbol) +.decl compat_required_count(c0:symbol,c1:number) +.decl compat_covers_member(c0:symbol,c1:symbol,c2:symbol) +.decl arg_type_covers(c0:symbol,c1:symbol) .decl overload_index_of(c0:symbol,c1:number) .decl overload_selectable(c0:symbol,c1:symbol) .decl owner_declares_param(c0:symbol,c1:symbol) From e59dfd6dcba71c3a5811bb125ecf96b9417023ea Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:57 -0700 Subject: [PATCH 36/37] query rules: never run a cached binary built for another machine A compiled query program left in the plugin's legacy dl/.cache is named by its rules alone, so a plugin copy synced from a Mac to a Linux machine (or back) carries the other machine's binary under the right name. impact and path then ran it and died with OSError: Exec format error. compiled() now reads the binary's header (ELF, Mach-O, PE and the CPU they name) and skips one built for another OS or CPU; the query falls back to the interpreter and compiles for this machine in the background, as it does when nothing is cached. A binary this machine compiled is still used. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/dl_program.py | 33 +++++++++++++++++-- tests/query_rules.py | 11 +++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index 1200cc9bd..6e9ec862d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -238,14 +238,43 @@ def _take_lock(lock): return False +_ELF_MACHINE = {62: 'x64', 183: 'arm64'} +_MACHO_CPU = {0x01000007: 'x64', 0x0100000c: 'arm64'} + + +def runs_here(p): + """whether the binary at p is an executable for this machine's OS and CPU, read from its header. A legacy dl/.cache + binary is named by its rules alone, so a plugin directory copied or synced from another machine (a Mac checkout + rsynced to a Linux VM) carries the other machine's binary under the right name, and running it was an + `OSError: Exec format error` in the middle of impact and path. Unknown formats and unreadable headers are trusted: + this rejects only a binary it can name as another machine's.""" + try: + with open(p, 'rb') as fh: h = fh.read(20) + except OSError: return False + plat = npm_platform() + if not plat or len(h) < 8: return True + o, a = plat.split('-') + if h[:4] == b'\x7fELF': + if o != 'linux': return False + m = _ELF_MACHINE.get(int.from_bytes(h[18:20], 'little' if h[5] == 1 else 'big')) if len(h) >= 20 else None + return m is None or m == a + if h[:4] in (b'\xcf\xfa\xed\xfe', b'\xce\xfa\xed\xfe'): # Mach-O, thin, little-endian + if o != 'darwin': return False + m = _MACHO_CPU.get(int.from_bytes(h[4:8], 'little')) + return m is None or m == a + if h[:4] in (b'\xca\xfe\xba\xbe', b'\xbe\xba\xfe\xca'): return o == 'darwin' # a universal Mach-O + if h[:2] == b'MZ': return o == 'win32' + return True + + def compiled(dl): """the compiled binary for these rules when one exists (the engine package's, the user cache's, or a legacy - dl/.cache one), else None. Does no work.""" + dl/.cache one) and runs on this machine, else None. Does no work.""" dl = resolve(dl); key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] shipped = packaged(stem, key) if shipped: return shipped for p in (os.path.join(cache_dir(), cached_name(stem, key)), os.path.join(LEGACY_CACHE, f'{stem}-{key}')): - if os.path.isfile(p) and os.access(p, os.X_OK): return p + if os.path.isfile(p) and os.access(p, os.X_OK) and runs_here(p): return p return None diff --git a/tests/query_rules.py b/tests/query_rules.py index a82a00af1..e27aed46a 100644 --- a/tests/query_rules.py +++ b/tests/query_rules.py @@ -93,6 +93,17 @@ def main(): env2 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-empty')) out, err, _ = py(e, "print(d.program('q.dl')[0])", env2) check(out == legacy, "a binary left in the plugin's dl/.cache by an older version is used", out + err) + # ... unless it was built for another machine: a plugin copy synced from a Mac to Linux (or back) carries the + # other machine's binary under the right name, and running it was an OSError (Exec format error) mid-query + g = plugin(work, 'copy-g'); os.makedirs(os.path.join(g, 'dl', '.cache')); foreign = os.path.join(g, 'dl', '.cache', f'q-{key}') + with open(foreign, 'wb') as fh: + fh.write(b'\xcf\xfa\xed\xfe\x0c\x00\x00\x01' + b'\0' * 24 if sys.platform.startswith('linux') + else b'\x7fELF\x02\x01\x01\x00' + b'\0' * 10 + b'\x3e\x00' + b'\0' * 12) + os.chmod(foreign, 0o755) + out, err, _ = py(g, "print(' '.join(d.program('q.dl')))", env2) + check(out.startswith('souffle ') and foreign not in out, "a dl/.cache binary built for another OS is not run: the interpreter answers", out + err) + out, _, _ = py(e, f"print(d.runs_here({legacy!r}))", env2) + check(out == 'True', 'control: the same check passes the binary this machine compiled', out) # 4. three queries at once on a cold cache: one compile, nothing left behind env3 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-race')); q3 = os.path.join(work, 'xdg-race', 'axiomcode', 'queries') From 63ead5dfd6af555389f973a87b632b8479ae9cc7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:25:10 -0700 Subject: [PATCH 37/37] impact: a caller through an interface or base method is a direct caller, and an interface-typed injection receives the bean (#1542, #1384) A service-layer caller holds an interface-typed field and calls through it. impact listed that caller only under "reaches those" for the implementation, and not at all for the interface method when the engine had narrowed the injected field to its one bean, so the answer read "few callers" or "the change is local". - graph_sql.via_base_rows: a call site typed on a base b (its targets are b and overrides of b, or b alone) is a direct row for each override the engine kept there, worded "calls it (via the interface)" or "(via the base class)"; resolved only where nothing else can run there, one of a set otherwise. For b itself a multi_inferred edge is the call the code names: "calls it", resolved. A call narrowed to one override is a caller of b when the receiver read there is a field declared with b's type. An override left out of a site's set gets no row, so a caller whose receiver resolves to a sibling implementation does not become a caller of this one. - graph_sql.bean_injections: an injection point the engine wired to one bean receives that bean whatever type it declares, so an @Autowired interface field or constructor parameter is "on the injected bean". - The rules (dl/impact.dl via_base, via_site, injected_bean, direct_edge) and the SQL port read the same rows; the plain row a via row replaces is suppressed, so a caller is listed once. IMPACT_VERSION 34. - The skill's impact reference names the new wording. - Cases: java/callers-through-the-interface, csharp/callers-through-the-interface, python/callers-through-the-base-class, each with a sibling-implementation near-miss control. java/next-step-reads-the-whole-answer now expects the call line the base-class caller is listed at. --- .../skills/axiomcode/reference/impact.md | 4 +- .../skills/axiomcode/scripts/ax_edges.py | 16 ++ .../skills/axiomcode/scripts/axiomcode-impact | 17 +- .../skills/axiomcode/scripts/dl/impact.dl | 24 ++- .../skills/axiomcode/scripts/graph_sql.py | 151 +++++++++++++++++- skills/axiomcode/reference/impact.md | 4 +- .../callers-through-the-interface/case.json | 18 +++ .../src/Shop.csproj | 1 + .../src/Store.cs | 26 +++ .../src/Users.cs | 37 +++++ .../callers-through-the-interface/case.json | 33 ++++ .../src/app/shapes/Circle.java | 5 + .../src/app/shapes/Shape.java | 5 + .../src/app/shapes/ShapeUser.java | 17 ++ .../src/app/shapes/Square.java | 5 + .../src/app/store/JdbcStore.java | 8 + .../src/app/store/Store.java | 5 + .../src/app/svc/CtorIface.java | 11 ++ .../src/app/svc/FieldConcrete.java | 11 ++ .../src/app/svc/FieldIface.java | 11 ++ .../src/app/svc/SelfMade.java | 7 + .../src/app/widgets/WidgetController.java | 16 ++ .../src/app/widgets/WidgetReport.java | 13 ++ .../src/app/widgets/WidgetService.java | 5 + .../src/app/widgets/WidgetServiceImpl.java | 11 ++ .../case.json | 4 +- .../callers-through-the-base-class/case.json | 15 ++ .../notifiers.py | 29 ++++ 28 files changed, 492 insertions(+), 17 deletions(-) create mode 100644 tests/cases/csharp/callers-through-the-interface/case.json create mode 100644 tests/cases/csharp/callers-through-the-interface/src/Shop.csproj create mode 100644 tests/cases/csharp/callers-through-the-interface/src/Store.cs create mode 100644 tests/cases/csharp/callers-through-the-interface/src/Users.cs create mode 100644 tests/cases/java/callers-through-the-interface/case.json create mode 100644 tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/store/Store.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java create mode 100644 tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java create mode 100644 tests/cases/python/callers-through-the-base-class/case.json create mode 100644 tests/cases/python/callers-through-the-base-class/notifiers.py diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index cf2eb57e2..67e77124f 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -79,7 +79,9 @@ line and names the constructor query to run; take that suggestion before acting field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor); `[in scope]` a reference by + — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against + the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the + base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 464dddeaa..68d176734 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -184,6 +184,22 @@ def direct_why(tier): return TIER_WHY.get(tier) or DIRECT_WHY.get(direct_cert(tier), 'calls it') +# …and a call written against a declaration this one is override-equivalent to (#1542): the caller names the interface +# or base method, and the declaration asked about is what runs there. Worded by the kind of type declaring the base. +# The service-layer shape (a controller holding an interface-typed field, the implementation behind it) is the common +# one; before this row its callers showed only under "reaches those", and an agent read that as "few callers". +VIA_BASE_WHY = { + 'interface': 'calls it (via the interface)', + 'class': 'calls it (via the base class)', +} +BODILESS_BASE = {'interface', 'protocol', 'trait'} # a base of this kind cannot itself be what runs at the call + + +def via_base_why(base_kind): + """what a direct row says for a caller that reaches this declaration through a base declaration of this kind""" + return VIA_BASE_WHY['interface' if (base_kind or '').lower() in BODILESS_BASE else 'class'] + + # ── entry points: what the reason token means, said in words ─────────────────────────────────────────────── # `entry_points.reason` is an engine token (`orm_hook`, `bean_ctor`, `framework_hook`). Printed raw inside a fixed # sentence it read "is a orm_hook entry point … Changing it changes what the outside world can call" for a model diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index da9846193..ce417d48e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -730,7 +730,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '36' # 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 = '37' # 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 @@ -832,6 +832,7 @@ class Impact: # the type written on the field's own declaration line. Without it `holds` is file scoped: one field of type T # in a file makes every type declared in that file a holder of T ftypes = [] + import graph_sql for rid, f in self.fields.items(): # THE SAME ID THE `field` FACT USED. This built `f:` unconditionally while `field` used the graph's # own id where there is one, so on any bundle whose fields carry ids the two relations never joined: every @@ -841,11 +842,8 @@ class Impact: fid = f['id'] or f"f:{rid}" if not f.get('file') or not f.get('line'): continue L = self.lines(f['file']); line = L[f['line'] - 1] if f['line'] <= len(L) else '' - m = re.search(rf'([A-Za-z_$][\w$.]*)\s*(?:<[^;=]*>)?\s*(?:\[\s*\])*\s+{re.escape(f["name"])}\s*[;=,)]', line) - if m: ftypes.append((fid, m.group(1).split('.')[-1])) - else: - m = re.search(rf'{re.escape(f["name"])}\s*:\s*([A-Za-z_$][\w$.]*)', line) # name: Type - if m: ftypes.append((fid, m.group(1).split('.')[-1])) + t = graph_sql.field_decl_type(line, f['name']) # `Type name;` or `name: Type` + if t: ftypes.append((fid, t)) W('field_type', ftypes) W('extends', [(r[0], r[1]) for r in g.q("SELECT type_id, ancestor_type_id FROM type_ancestors")]) W('nested', [(r[0], r[1]) for r in g.q("SELECT type_id, outer_type_id FROM nesting")] if g.has('nesting') else []) @@ -1295,6 +1293,13 @@ class Impact: # hook's fast path read the same rows import graph_sql W('registers', sorted({(t, by, kind) for t, rows in graph_sql._registers(g.q).items() for by, kind in rows})) + # the bean an injection point was WIRED to, whatever type the point declares (#1384), and the callers that reach + # a declaration through a base it is override-equivalent to (#1542): both graph_sql's, so the rules and the SQL + # port read the same rows + W('injected_bean', graph_sql.bean_injections(g.q)) + via, via_sites = graph_sql.via_base_rows(g.q, self.lines, stubs) + W('via_base', sorted({(c, m, why, cert, g.site_file(f) if f else '', l, e) for c, m, why, cert, f, l, e in via})) + W('via_site', sorted({(c, m, g.site_file(f) if f else '', l) for c, m, f, l in via_sites})) W('bean_factory', sorted(set(factories))) # A CROSS-PROCESS HOP IS A DEPENDENT, AND IT WAS IN THE DATABASE UNREAD (#1108). # framework-behavior emits remote_edge for a hop no call site expresses -- a queue, a route, a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 60036a945..0b616e612 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -332,7 +332,20 @@ route_site(f, l) :- registration(_, f, l, "route", _, _). // fast path reads the same one, so the rules and the fast path cannot disagree about a tier — which // is how the two copies of the binary test drifted apart from the rest of the vocabulary to begin with. .decl cert_tier(tier:symbol, cert:symbol, why:symbol) .input cert_tier -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l), !via_site(c, m, f, l). +// A CALL WRITTEN AGAINST A BASE THIS DECLARATION IS OVERRIDE-EQUIVALENT TO (#1542). The service-layer shape: a controller +// holds an interface-typed field and calls through it. Before this the caller of an implementation showed only under +// "reaches those" (the dispatch hop), and a caller the engine had NARROWED to the implementation (an injected field whose +// bean it resolved) was missing from the interface method altogether, so `impact` there said the change was local. +// The rows are graph_sql.via_base_rows, read by the SQL port too: for the base itself, a multi_inferred edge is the +// call the code names ("calls it", resolved); for each override the engine kept at that site, "calls it (via the +// interface)", resolved only when nothing else can run there; for a narrowed call, the base when the receiver read at +// the call is a field declared with the base's type. An override the engine left out of a site's set gets no row, so a +// caller whose receiver resolves to a sibling implementation does not become a caller of this one. `via_site` is the +// plain row each one replaces, so a caller is not listed twice for one call. +.decl via_base(c:symbol, m:symbol, why:symbol, cert:symbol, f:symbol, l:number, e:symbol) .input via_base +.decl via_site(c:symbol, m:symbol, f:symbol, l:number) .input via_site +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), via_base(c, m, why, cert, f, l, _), !is_target_decl(q, c). // A call written inside a mock's stub or verification (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): the exporter // gives it the tier "stub" (ax_edges.stub_sites). It names the method, so a rename or a new parameter breaks it, and it // runs nothing: the receiver is a mock. Its own row, whatever else the caller does; it is not an edge of the closure. @@ -352,13 +365,18 @@ bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), c .decl receives_bean(t:symbol, into:symbol) receives_bean(t, into) :- injected(t, into, _), typ(into, _, _). receives_bean(t, into) :- injected(t, x, _), owner(x, into). +// …and a point the engine WIRED to this bean, whatever type it declares: an `@Autowired Store store` given the JdbcStore +// bean receives it through the container, and keyed on the declared type alone its caller "obtained it itself" (#1384) +.decl injected_bean(t:symbol, into:symbol) .input injected_bean +receives_bean(t, into) :- injected_bean(t, into), typ(into, _, _). +receives_bean(t, into) :- injected_bean(t, x), owner(x, into). direct(q, c, "uses", "calls it from inside the same bean — through this, not through the container proxy, so an added proxied annotation does NOT apply to this caller", "resolved", f, l) :- bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on the injected bean — the container proxy is on the path, so an added proxied annotation applies here", "resolved", f, l) :- bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on an instance it obtained itself, not from the container — no proxy on the path, so an added proxied annotation does not apply here", "resolved", f, l) :- bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". -direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l). +direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l), !via_site(c, m, f, l). // `!is_target_decl`: `this.rows.find(...)` inside `OrdersService.find` is the ARRAY's find, and with the receiver // untyped it matched the target by name — printing the declaration as a reader of itself. Worse, that row made the // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are @@ -668,6 +686,8 @@ seed(q, m) :- seed_of(q, m). // merely that the caller calls something .decl direct_edge(q:symbol, c:symbol, callee:symbol) direct_edge(q, c, m) :- target(q, "method", m, _), calls(c, m, _, _, _). +// a via_base row claims the engine's edge at its site: the base's, or the override the call was narrowed to (#1542) +direct_edge(q, c, e) :- target(q, "method", m, _), via_base(c, m, _, _, _, _, e), !is_target_decl(q, c). direct_edge(q, c, a) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, _), member(t, a, an, _), calls(c, a, _, _, _). direct_edge(q, c, k) :- target(q, "field", fl, _), field(fl, t, _, _, _), member(t, k, _, "constructor"), calls(c, k, _, _, _). direct_edge(q, c, k) :- target(q, "field", fl, _), field(fl, t, _, _, _), holds(h, t), member(h, k, _, "constructor"), calls(c, k, _, _, _). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 70a75e3fe..62ae4ecb3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -696,6 +696,131 @@ def _has(q, table): except Exception: return False +def field_decl_type(line, name): + """the type written on a field's declaration line, as a simple name: `private final Store store;`, `IStore _store;`, + `store: Store`. None where the line does not say. The impact exporter's `field_type` fact reads the same pattern.""" + m = re.search(rf'([A-Za-z_$][\w$.]*)\s*(?:<[^;=]*>)?\s*(?:\[\s*\])*\s+{re.escape(name)}\s*[;=,)]', line) + if m: return m.group(1).split('.')[-1] + m = re.search(rf'{re.escape(name)}\s*:\s*([A-Za-z_$][\w$.]*)', line) + return m.group(1).split('.')[-1] if m else None + + +def bean_injections(q): + """(bean type, receiver) for every injection point the engine wired to ONE bean (`ext_di_edge` known_bean / xml_ref): + a field injection's receiver is the type declaring it, a constructor or setter injection's is the method. Keyed on + the bean the container hands over, not the declared type of the point: an `@Autowired Store store` wired to + `JdbcStore` receives the JdbcStore bean, and read by the declared type alone the proxy rule said its caller + "obtained the instance itself" (#1384).""" + if not (_has(q, 'ext_di_edge') and _has(q, 'ext_inject_point')): return [] + return sorted({(bt, tgt) for bt, tgt in q("""SELECT d.c4, COALESCE(s.id, o.id) FROM ext_di_edge d + JOIN ext_inject_point i ON i.c1 = d.c0 + LEFT JOIN symbols s ON s.method_id = i.c4 + LEFT JOIN symbols o ON o.type_id = i.c3 AND o.method_id IS NULL + WHERE d.c5 IN ('known_bean', 'xml_ref') AND d.c4 NOT IN ('', '-') + AND COALESCE(s.id, o.id) IS NOT NULL""") if bt and tgt}) + + +def via_base_rows(q, lines=None, stubs=frozenset(), only=None): + """The callers that reach a declaration through a base declaration it is override-equivalent to (#1542). + + Returns (rows, sites): rows are (caller, declaration, why, cert, raw file, line, the callee of the engine's edge at + that site, which is what `verified:` looks up), and `sites` the (caller, declaration, raw file, line) whose plain + `calls it` row a row here replaces. `only`: the declarations asked about. + + A call site is TYPED ON A BASE b when the engine's targets there are b and overrides of b only, or b alone: + · for b itself, a multi_inferred edge is the call the code names, so it is `calls it`, resolved; + · for each override m the engine kept at that site (every override of b when b is the only target), it is + "calls it (via the interface)": resolved when m is the only thing that can run there (b has no body and one + override), one of a set otherwise. An override the engine left OUT of the site's set gets nothing, so a caller + whose receiver resolves to a sibling implementation never becomes a caller of this one. + A call the engine NARROWED to one override o (an injected field whose bean it resolved) names b in the source but + carries no edge to b: that caller is a caller of b when the receiver it reads there is a field declared with b's + type. Without it, `impact` on the interface method said nothing depended on it (#1542).""" + if not (_has(q, 'call_edges') and _has(q, 'call_sites')): return [], set() + pairs = set() + if _has(q, 'overrides'): + pairs |= {(b, o) for b, o in q("SELECT method_id, overriding_method_id FROM overrides")} + if _has(q, 'dispatch_candidates'): + pairs |= {(b, o) for b, o in q("SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE basis <> 'value'")} + down = collections.defaultdict(set) + for b, o in pairs: + if b and o and b != o: down[b].add(o) + if not down: return [], set() + subs = {} + for b in down: # every override of b, through intermediate bases too + seen, stack = set(), list(down[b]) + while stack: + x = stack.pop() + if x in seen or x == b: continue + seen.add(x); stack += down.get(x, ()) + subs[b] = seen + up = collections.defaultdict(set) + for b, os_ in subs.items(): + for o in os_: up[o].add(b) + want = set(subs) | set(up) if only is None else set(only) | {b for m in only for b in up.get(m, ())} | \ + {o for m in only for o in subs.get(m, ())} + if not want: return [], set() + kind_of = {} + def base_kind(b): + if b not in kind_of: + r = q("SELECT owner FROM symbols WHERE id=?", b); o = r[0][0] if r and r[0][0] else None + k = q("SELECT kind FROM symbols WHERE display=? AND type_id IS NOT NULL AND method_id IS NULL", o) if o else [] + kind_of[b] = (k[0][0] if k else '', (o or '').split('.')[-1]) + return kind_of[b] + by_site = collections.defaultdict(dict); where = {} + wl = sorted(want) + for i in range(0, len(wl), 500): + chunk = wl[i:i + 500]; ph = ','.join('?' * len(chunk)) + for sid, c, m, t, f, l, sc, el, ec in q(f"""SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, + s.start_line, s.start_column, s.end_line, s.end_column + FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.call_site_id IN (SELECT call_site_id FROM call_edges WHERE callee_method_id IN ({ph})) + AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL""", *chunk): + if sid in stubs: continue + by_site[sid][m] = t; where[sid] = (c, f or '', l or 0, sc or 0, el or l or 0, ec or 0) + ftype = {} + def recv_types(c, sp): + """the declared types of the fields caller c reads inside the call's span""" + if not (lines and _has(q, 'field_access')): return set() + out = set() + for fid, fl, fc in q("SELECT field_id, start_line, start_column FROM field_access WHERE caller_id=? AND access='read'", c): + if not (fid and fl) or not ((sp[0], sp[1]) <= (fl, fc or 0) <= (sp[2], sp[3] or 10 ** 6)): continue + if fid not in ftype: + r = q("SELECT name, file, line FROM symbols WHERE id=?", fid) + ln = '' + if r and r[0][1] and r[0][2]: + L = lines(r[0][1]) or []; ln = L[r[0][2] - 1] if r[0][2] <= len(L) else '' + ftype[fid] = field_decl_type(ln, r[0][0]) if r and r[0][0] else None + if ftype[fid]: out.add(ftype[fid]) + return out + sure = ('resolved', 'one of a set') + rows, sites = [], set() + for sid, T in by_site.items(): + c, f, l, sc, el, ec = where[sid] + if any(ax_edges.direct_cert(t) not in sure for t in T.values()): continue + typed_on = [b for b in T if b in subs and set(T) - {b} <= subs[b]] + for b in typed_on: + if T[b] == 'multi_inferred': + rows.append((c, b, 'calls it', 'resolved', f, l, b)); sites.add((c, b, f, l)) + bk, _bn = base_kind(b) + n = len(subs[b]) + (0 if bk.lower() in ax_edges.BODILESS_BASE else 1) + others = set(T) - {b} + for m in subs[b]: + if others and m not in others: continue + rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if n == 1 else 'one of a set', f, l, m if m in T else b)) + sites.add((c, m, f, l)) + if typed_on or len(T) != 1: continue + (o, t), = T.items() + if t == 'multi_inferred' or not up.get(o): continue + rt = None + for b in sorted(up[o]): + bk, bn = base_kind(b) + if not bn: continue + if rt is None: rt = recv_types(c, (l, sc, el, ec)) + if bn in rt: rows.append((c, b, ax_edges.via_base_why(bk), ax_edges.direct_cert(t), f, l, o)) + return rows, sites + + def _bean_call(q, ids, sites): """The container-bean layer on `calls it`, and the only rules in `direct` that a bundle without a container never exercises — which is why jackson (0 rows in ext_bean_def) was clean on it and keycloak (213) was not. @@ -732,6 +857,11 @@ def type_of(sid): WHERE i.c2 <> '' AND COALESCE(s.id, o.id) IS NOT NULL"""): if t != ot: continue recv.add(tgt if tgt in istype else type_of(tgt)) + # …and a point the engine wired to this bean whatever type it declares: an interface-typed @Autowired field or + # constructor parameter receives the bean too (#1384) + # receives_bean(t,into) :- injected_bean(t,into), typ(into,_,_) ; injected_bean(t,x), owner(x,into) + for t, tgt in bean_injections(q): + if t == ot: recv.add(tgt if tgt in istype else type_of(tgt)) # a bean call site, any tier but the set, a stub and an event: the event system invokes a listener on the container's # bean, so the proxy IS on that path and "an instance it obtained itself" would be false (#1391) callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch', ax_edges.STUB_TIER)} @@ -753,7 +883,7 @@ def type_of(sid): STUB_BYNAME_WHY = 'stubs a method of this name on a mock (receiver not typed): the real method does not run there' -def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): +def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None, via=None): """direct(q,c,role,why,cert,f,l) for a method target — the rows the answer groups by *why* and *how sure*. calls it resolved a resolved edge @@ -770,14 +900,20 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # the lowest. Leaving the order to the table printed a different site (254 against 257) for the same caller. # a site inside a mock's stub or verification carries the tier "stub", exactly as the rules' `calls` fact does stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) - sites = [(c, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, t, f, l in q( - f"""SELECT e.call_site_id, e.caller_id, e.tier, s.file_path, s.start_line + raw = [(c, m, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, m, t, f, l in q( + f"""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_method_id IN ({ph}) AND e.callee_provenance='client' ORDER BY s.start_line""", *ids)] + sites = [(c, t, f, l) for c, _m, t, f, l in raw] bean_callers, why_of = _bean_call(q, ids, sites) + # a call written against a base this declaration is override-equivalent to (#1542): its row replaces the plain one + # direct(q,c,"uses",why,cert,f,l) :- target(q,"method",m,_), via_base(c,m,why,cert,f,l), c != m + via, via_sites = via if via is not None else via_base_rows(q, lines, stubs, set(ids)) routes = {(f, l): w for _d, f, l, k, _key, w in ax_registration.registrations(q, rel) if k == 'route'} - for c, tier, f, l in sites: + for c, m, tier, f, l in raw: + # `!via_site(c, m, f, l)` on the plain call rules: the via_base row below says it instead + if tier != ax_edges.STUB_TIER and (c, m, f or '', l or 0) in via_sites: continue cert = ax_edges.direct_cert(tier) if tier == ax_edges.STUB_TIER: rows.append((c, 'uses', ax_edges.DIRECT_WHY[cert], cert, f or '', l or 0)) @@ -795,6 +931,8 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # `calls(c, m, _, f, l)` with no tier test, so a multi_inferred site of a bean caller is a row here too. for c, tier, f, l in sites: if c in bean_callers and tier != ax_edges.STUB_TIER: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) + idset = set(ids) + rows += [(c, 'uses', why, cert, f, l) for c, m, why, cert, f, l, _e in via if m in idset and c not in idset] names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} seen = {r[0] for r in rows} for n in names: @@ -2674,7 +2812,8 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non mids = sorted(by_kind['method']) mph = ','.join('?' * len(mids)) con += contract_for_method(q, mids) - d = direct_for_method(q, mids, code, rel, at, lines) + via = via_base_rows(q, lines, stubs, set(mids)) + d = direct_for_method(q, mids, code, rel, at, lines, via) # direct(q,c,"uses",…,"framework","",0) :- target(q,"method",m,_), framework(c,m,…) (#1509) if _has(q, 'ext_framework_edge'): d += [(c, 'uses', framework_why(mech, det, conf), 'framework', '', 0) for c, mech, det, conf in @@ -2685,6 +2824,8 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non seeds |= set(mids) | {c for c, _ in con} de += q(f"""SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IN ({mph}) AND callee_provenance='client'""", *mids) + # direct_edge(q,c,e) :- target(q,"method",m,_), via_base(c,m,_,_,_,_,e), !is_target_decl(q,c) + de += sorted({(c, e) for c, m, _w, _c, _f, _l, e in via[0] if m in set(mids) and c not in set(mids)}) byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w) and _w != STUB_BYNAME_WHY} - seeds) diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index cf2eb57e2..67e77124f 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -79,7 +79,9 @@ line and names the constructor query to run; take that suggestion before acting field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor); `[in scope]` a reference by + — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against + the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the + base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java diff --git a/tests/cases/csharp/callers-through-the-interface/case.json b/tests/cases/csharp/callers-through-the-interface/case.json new file mode 100644 index 000000000..5ef233527 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a caller holding the interface in a constructor-injected field is a direct caller of the implementation, via the interface, resolved when it is the only one (#1542)", + "run": ["impact", "SqlStore.Save"], + "want": ["[resolved] OrderService.Place", "— calls it (via the interface)"]}, + {"why": "control: a caller that builds the concrete class keeps its plain row", + "run": ["impact", "SqlStore.Save"], + "want": ["[resolved] SelfMade.Place"]}, + {"why": "with two implementations, a call through the interface is one of a set for each, worded as via the interface", + "run": ["impact", "Circle.Area"], + "want": ["[one of a set] ShapeUser.Total", "calls it (via the interface)"]}, + {"why": "near-miss control: a caller whose receiver is typed on the other implementation is not a caller of this one", + "run": ["impact", "Circle.Area"], + "avoid": ["ShapeUser.SquareOnly"]}, + {"why": "the interface method itself: the call through the interface names it, resolved", + "run": ["impact", "IShape.Area"], + "want": ["[resolved] ShapeUser.Total"], + "avoid": ["[one of a set] ShapeUser.Total"]}]} diff --git a/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj b/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/callers-through-the-interface/src/Store.cs b/tests/cases/csharp/callers-through-the-interface/src/Store.cs new file mode 100644 index 000000000..f5d4069c3 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Store.cs @@ -0,0 +1,26 @@ +namespace App; + +public interface IStore +{ + void Save(string k); +} + +public class SqlStore : IStore +{ + public void Save(string k) { } +} + +public interface IShape +{ + double Area(); +} + +public class Circle : IShape +{ + public double Area() => 3.14; +} + +public class Square : IShape +{ + public double Area() => 1.0; +} diff --git a/tests/cases/csharp/callers-through-the-interface/src/Users.cs b/tests/cases/csharp/callers-through-the-interface/src/Users.cs new file mode 100644 index 000000000..e9f76b97a --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Users.cs @@ -0,0 +1,37 @@ +namespace App; + +public class OrderService +{ + private readonly IStore _store; + + public OrderService(IStore store) + { + _store = store; + } + + public void Place() + { + _store.Save("k"); + } +} + +public class SelfMade +{ + public void Place() + { + new SqlStore().Save("k"); + } +} + +public class ShapeUser +{ + public double Total(IShape s) + { + return s.Area(); + } + + public double SquareOnly(Square sq) + { + return sq.Area(); + } +} diff --git a/tests/cases/java/callers-through-the-interface/case.json b/tests/cases/java/callers-through-the-interface/case.json new file mode 100644 index 000000000..73a686bd0 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/case.json @@ -0,0 +1,33 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a caller holding the interface in an @Autowired field, which the engine narrowed to the one bean, is a direct caller of the interface method, and the change is not local (#1542)", + "run": ["impact", "WidgetService.build"], + "want": ["WidgetController.get src/app/widgets/WidgetController.java:14 — calls it (via the interface)", "WidgetReport.line"], + "avoid": ["the change is local", "directly touches it: nothing"]}, + {"why": "a call typed on an interface with one implementation names the interface method: resolved, not one of a set", + "run": ["impact", "WidgetService.build"], + "want": ["[resolved] WidgetReport.line"], + "avoid": ["[one of a set] WidgetReport.line"]}, + {"why": "the implementation lists the interface-typed caller as a direct caller via the interface, resolved when it is the only implementation", + "run": ["impact", "WidgetServiceImpl.build"], + "want": ["[resolved] WidgetReport.line src/app/widgets/WidgetReport.java:11 — calls it (via the interface)"]}, + {"why": "an @Autowired interface field the engine wired to the bean receives the injected bean, not an instance it obtained itself (#1384)", + "run": ["impact", "JdbcStore.save"], + "want": ["FieldIface.place src/app/svc/FieldIface.java:10 — calls it on the injected bean", "CtorIface.place src/app/svc/CtorIface.java:10 — calls it on the injected bean", "FieldConcrete.place src/app/svc/FieldConcrete.java:10 — calls it on the injected bean"], + "avoid": ["FieldIface.place src/app/svc/FieldIface.java:10 — calls it on an instance it obtained itself", "CtorIface.place src/app/svc/CtorIface.java:10 — calls it on an instance it obtained itself"]}, + {"why": "control: new JdbcStore() is still an instance the caller obtained itself", + "run": ["impact", "JdbcStore.save"], + "want": ["SelfMade.place src/app/svc/SelfMade.java:6 — calls it on an instance it obtained itself"]}, + {"why": "the interface method lists the callers whose receiver is a field of the interface type, and no caller whose receiver is the concrete class", + "run": ["impact", "Store.save"], + "want": ["CtorIface.place src/app/svc/CtorIface.java:10 — calls it (via the interface)", "FieldIface.place src/app/svc/FieldIface.java:10 — calls it (via the interface)"], + "avoid": ["SelfMade.place src/app/svc/SelfMade.java:6 — calls it", "FieldConcrete.place src/app/svc/FieldConcrete.java:10 — calls it", "directly touches it: nothing"]}, + {"why": "with two implementations, a call through the interface is one of a set for each of them, worded as via the interface", + "run": ["impact", "Circle.area"], + "want": ["[one of a set] ShapeUser.total src/app/shapes/ShapeUser.java:5 — calls it (via the interface)"]}, + {"why": "near-miss control: a caller whose receiver is typed on the OTHER implementation is not a caller of this one", + "run": ["impact", "Circle.area"], + "avoid": ["ShapeUser.squareOnly"]}, + {"why": "control: the other implementation keeps its own concrete caller", + "run": ["impact", "Square.area"], + "want": ["[resolved] ShapeUser.squareOnly src/app/shapes/ShapeUser.java:9 — calls it"]}]} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java new file mode 100644 index 000000000..ea7c954c5 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java @@ -0,0 +1,5 @@ +package app.shapes; + +public class Circle implements Shape { + public double area() { return 3.14; } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java new file mode 100644 index 000000000..824efb176 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java @@ -0,0 +1,5 @@ +package app.shapes; + +public interface Shape { + double area(); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java new file mode 100644 index 000000000..0c2899dae --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java @@ -0,0 +1,17 @@ +package app.shapes; + +public class ShapeUser { + public double total(Shape s) { + return s.area(); + } + + public double squareOnly(Square sq) { + return sq.area(); + } + + public double make() { + Shape a = new Circle(); + Shape b = new Square(); + return a.area() + b.area(); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java new file mode 100644 index 000000000..48db4d45f --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java @@ -0,0 +1,5 @@ +package app.shapes; + +public class Square implements Shape { + public double area() { return 1.0; } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java b/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java new file mode 100644 index 000000000..da0420037 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java @@ -0,0 +1,8 @@ +package app.store; + +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcStore implements Store { + public void save(String k) { } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/store/Store.java b/tests/cases/java/callers-through-the-interface/src/app/store/Store.java new file mode 100644 index 000000000..fec90f824 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/store/Store.java @@ -0,0 +1,5 @@ +package app.store; + +public interface Store { + void save(String k); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java b/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java new file mode 100644 index 000000000..2acb67b67 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.Store; +import org.springframework.stereotype.Service; + +@Service +public class CtorIface { + private final Store store; + public CtorIface(Store store) { this.store = store; } + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java new file mode 100644 index 000000000..ca65bd382 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.JdbcStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class FieldConcrete { + @Autowired private JdbcStore store; + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java new file mode 100644 index 000000000..bc15a78ca --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.Store; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class FieldIface { + @Autowired private Store store; + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java b/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java new file mode 100644 index 000000000..44c30140f --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java @@ -0,0 +1,7 @@ +package app.svc; + +import app.store.JdbcStore; + +public class SelfMade { + public void place() { new JdbcStore().save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java new file mode 100644 index 000000000..f050f17c0 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java @@ -0,0 +1,16 @@ +package app.widgets; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WidgetController { + @Autowired + private WidgetService widgetService; + + @GetMapping("/widgets") + public String get(String name) { + return widgetService.build(name); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java new file mode 100644 index 000000000..0a71a5a01 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java @@ -0,0 +1,13 @@ +package app.widgets; + +public class WidgetReport { + private final WidgetService service; + + public WidgetReport(WidgetService service) { + this.service = service; + } + + public String line(String name) { + return service.build(name); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java new file mode 100644 index 000000000..12976a34d --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java @@ -0,0 +1,5 @@ +package app.widgets; + +public interface WidgetService { + String build(String name); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java new file mode 100644 index 000000000..c652a7e9a --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java @@ -0,0 +1,11 @@ +package app.widgets; + +import org.springframework.stereotype.Service; + +@Service +public class WidgetServiceImpl implements WidgetService { + @Override + public String build(String name) { + return "widget:" + name; + } +} diff --git a/tests/cases/java/next-step-reads-the-whole-answer/case.json b/tests/cases/java/next-step-reads-the-whole-answer/case.json index 507a60bdb..aa4993759 100644 --- a/tests/cases/java/next-step-reads-the-whole-answer/case.json +++ b/tests/cases/java/next-step-reads-the-whole-answer/case.json @@ -115,7 +115,7 @@ ] }, { - "why": "a method reached only through the generic abstract base it overrides (the base's own body calls the abstract method) names that caller in next:, the caller the reach above printed, instead of calling the change local", + "why": "a method reached only through the generic abstract base it overrides (the base's own body calls the abstract method) names that caller in next:, at the call written against the base (a direct caller via the base class, #1542), instead of calling the change local", "run": [ "impact", "RowExtractor.add" @@ -123,7 +123,7 @@ "want": [ "how each is reached", "Extractor.extractData", - "place(s) bound to it: src/com/example/Extractor.java:14" + "place(s) bound to it: src/com/example/Extractor.java:19" ], "avoid": [ "the change is local" diff --git a/tests/cases/python/callers-through-the-base-class/case.json b/tests/cases/python/callers-through-the-base-class/case.json new file mode 100644 index 000000000..b0bbaa57f --- /dev/null +++ b/tests/cases/python/callers-through-the-base-class/case.json @@ -0,0 +1,15 @@ +{"lang": "python", + "checks": [ + {"why": "a caller typed on the base class is a direct caller of each subclass override, via the base class: one of a set, since the base or the other subclass may run there (#1542)", + "run": ["impact", "MailNotifier.send"], + "want": ["[one of a set] alert", "[one of a set] Desk.ring", "calls it (via the base class)"]}, + {"why": "near-miss control: a caller whose receiver is annotated with the other subclass is not a caller of this one", + "run": ["impact", "MailNotifier.send"], + "avoid": ["sms_only"]}, + {"why": "control: the other subclass keeps its own concrete caller", + "run": ["impact", "SmsNotifier.send"], + "want": ["[resolved] sms_only"]}, + {"why": "the base method: the call written against it names it, resolved", + "run": ["impact", "Notifier.send"], + "want": ["[resolved] alert", "[resolved] Desk.ring"], + "avoid": ["[one of a set] alert"]}]} diff --git a/tests/cases/python/callers-through-the-base-class/notifiers.py b/tests/cases/python/callers-through-the-base-class/notifiers.py new file mode 100644 index 000000000..67217f83f --- /dev/null +++ b/tests/cases/python/callers-through-the-base-class/notifiers.py @@ -0,0 +1,29 @@ +class Notifier: + def send(self, msg: str) -> str: + return msg + + +class MailNotifier(Notifier): + def send(self, msg: str) -> str: + return "mail:" + msg + + +class SmsNotifier(Notifier): + def send(self, msg: str) -> str: + return "sms:" + msg + + +def alert(n: Notifier) -> str: + return n.send("x") + + +def sms_only(n: SmsNotifier) -> str: + return n.send("y") + + +class Desk: + def __init__(self, notifier: Notifier): + self.notifier = notifier + + def ring(self) -> str: + return self.notifier.send("z")